Commit 2867dfc1 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch '8333-add-related-epics-support' into 'master'

Add support for attaching epics to an epic

Closes #8333

See merge request gitlab-org/gitlab-ee!8637
parents f5351b37 7f37b273
...@@ -1040,6 +1040,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do ...@@ -1040,6 +1040,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
t.integer "state", limit: 2, default: 1, null: false t.integer "state", limit: 2, default: 1, null: false
t.integer "closed_by_id" t.integer "closed_by_id"
t.datetime "closed_at" t.datetime "closed_at"
t.integer "parent_id"
t.index ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree t.index ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree
t.index ["author_id"], name: "index_epics_on_author_id", using: :btree t.index ["author_id"], name: "index_epics_on_author_id", using: :btree
t.index ["closed_by_id"], name: "index_epics_on_closed_by_id", using: :btree t.index ["closed_by_id"], name: "index_epics_on_closed_by_id", using: :btree
...@@ -1047,6 +1048,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do ...@@ -1047,6 +1048,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
t.index ["group_id"], name: "index_epics_on_group_id", using: :btree t.index ["group_id"], name: "index_epics_on_group_id", using: :btree
t.index ["iid"], name: "index_epics_on_iid", using: :btree t.index ["iid"], name: "index_epics_on_iid", using: :btree
t.index ["milestone_id"], name: "index_milestone", using: :btree t.index ["milestone_id"], name: "index_milestone", using: :btree
t.index ["parent_id"], name: "index_epics_on_parent_id", using: :btree
t.index ["start_date"], name: "index_epics_on_start_date", using: :btree t.index ["start_date"], name: "index_epics_on_start_date", using: :btree
end end
...@@ -3227,6 +3229,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do ...@@ -3227,6 +3229,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
add_foreign_key "epic_issues", "epics", on_delete: :cascade add_foreign_key "epic_issues", "epics", on_delete: :cascade
add_foreign_key "epic_issues", "issues", on_delete: :cascade add_foreign_key "epic_issues", "issues", on_delete: :cascade
add_foreign_key "epic_metrics", "epics", on_delete: :cascade add_foreign_key "epic_metrics", "epics", on_delete: :cascade
add_foreign_key "epics", "epics", column: "parent_id", name: "fk_25b99c1be3", on_delete: :cascade
add_foreign_key "epics", "milestones", on_delete: :nullify add_foreign_key "epics", "milestones", on_delete: :nullify
add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
......
...@@ -13,6 +13,7 @@ import { status, stateEvent } from '../../constants'; ...@@ -13,6 +13,7 @@ import { status, stateEvent } from '../../constants';
export default { export default {
name: 'EpicShowApp', name: 'EpicShowApp',
epicsPathIdSeparator: '&',
components: { components: {
epicHeader, epicHeader,
epicSidebar, epicSidebar,
...@@ -44,6 +45,11 @@ export default { ...@@ -44,6 +45,11 @@ export default {
required: true, required: true,
type: Boolean, type: Boolean,
}, },
subepicsSupported: {
type: Boolean,
required: false,
default: true,
},
markdownPreviewPath: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
...@@ -82,6 +88,10 @@ export default { ...@@ -82,6 +88,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
epicLinksEndpoint: {
type: String,
required: true,
},
issueLinksEndpoint: { issueLinksEndpoint: {
type: String, type: String,
required: true, required: true,
...@@ -146,6 +156,11 @@ export default { ...@@ -146,6 +156,11 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
parent: {
type: Object,
required: false,
default: () => ({}),
},
participants: { participants: {
type: Array, type: Array,
required: true, required: true,
...@@ -198,7 +213,9 @@ export default { ...@@ -198,7 +213,9 @@ export default {
return { return {
// Epics specific configuration // Epics specific configuration
issuableRef: '', issuableRef: '',
hasRelatedEpicsFeature: this.subepicsSupported && gon.features && gon.features.epicLinks,
projectPath: this.groupPath, projectPath: this.groupPath,
parentEpic: this.parent ? this.parent : {},
projectNamespace: '', projectNamespace: '',
service: new EpicsService({ service: new EpicsService({
endpoint: this.endpoint, endpoint: this.endpoint,
...@@ -293,6 +310,7 @@ export default { ...@@ -293,6 +310,7 @@ export default {
:initial-participants="participants" :initial-participants="participants"
:initial-subscribed="subscribed" :initial-subscribed="subscribed"
:initial-todo-exists="todoExists" :initial-todo-exists="todoExists"
:parent="parentEpic"
:namespace="namespace" :namespace="namespace"
:update-path="updateEndpoint" :update-path="updateEndpoint"
:labels-path="labelsPath" :labels-path="labelsPath"
...@@ -302,12 +320,24 @@ export default { ...@@ -302,12 +320,24 @@ export default {
:labels-web-url="labelsWebUrl" :labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl" :epics-web-url="epicsWebUrl"
/> />
<related-issues-root
v-if="hasRelatedEpicsFeature"
:endpoint="epicLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
:allow-auto-complete="false"
:path-id-separator="$options.epicsPathIdSeparator"
:title="__('Epics')"
css-class="js-related-epics-block"
/>
<related-issues-root <related-issues-root
:endpoint="issueLinksEndpoint" :endpoint="issueLinksEndpoint"
:can-admin="canAdmin" :can-admin="canAdmin"
:can-reorder="canAdmin" :can-reorder="canAdmin"
:allow-auto-complete="false" :allow-auto-complete="false"
title="Issues" :title="__('Issues')"
css-class="js-related-issues-block"
path-id-separator="#"
/> />
</div> </div>
</div> </div>
......
...@@ -12,6 +12,7 @@ import SidebarTodo from '~/sidebar/components/todo_toggle/todo.vue'; ...@@ -12,6 +12,7 @@ import SidebarTodo from '~/sidebar/components/todo_toggle/todo.vue';
import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue'; import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
import SidebarItemEpic from 'ee/sidebar/components/sidebar_item_epic.vue';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import SidebarDatePicker from './sidebar_date_picker.vue'; import SidebarDatePicker from './sidebar_date_picker.vue';
...@@ -33,6 +34,7 @@ export default { ...@@ -33,6 +34,7 @@ export default {
SidebarDatePicker, SidebarDatePicker,
SidebarCollapsedGroupedDatePicker, SidebarCollapsedGroupedDatePicker,
SidebarLabelsSelect, SidebarLabelsSelect,
SidebarItemEpic,
SidebarParticipants, SidebarParticipants,
SidebarSubscriptions, SidebarSubscriptions,
}, },
...@@ -116,6 +118,10 @@ export default { ...@@ -116,6 +118,10 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
parent: {
type: Object,
required: true,
},
namespace: { namespace: {
type: String, type: String,
required: false, required: false,
...@@ -549,6 +555,9 @@ export default { ...@@ -549,6 +555,9 @@ export default {
@toggleCollapse="toggleSidebarRevealLabelsDropdown" @toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</sidebar-labels-select >{{ __('None') }}</sidebar-labels-select
> >
<div class="block parent-epic">
<sidebar-item-epic :block-title="__('Parent epic')" :initial-epic="parent" />
</div>
<sidebar-participants :participants="initialParticipants" @toggleCollapse="toggleSidebar" /> <sidebar-participants :participants="initialParticipants" @toggleCollapse="toggleSidebar" />
<sidebar-subscriptions <sidebar-subscriptions
:loading="savingSubscription" :loading="savingSubscription"
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import $ from 'jquery'; import $ from 'jquery';
import GfmAutoComplete from '~/gfm_auto_complete'; import GfmAutoComplete from '~/gfm_auto_complete';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import issueToken from './issue_token.vue'; import issueToken from './issue_token.vue';
export default { export default {
...@@ -60,7 +59,6 @@ export default { ...@@ -60,7 +59,6 @@ export default {
mounted() { mounted() {
const $input = $(this.$refs.input); const $input = $(this.$refs.input);
if (this.allowAutoComplete) { if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources); this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, { this.gfmAutoComplete.setup($input, {
...@@ -83,7 +81,10 @@ export default { ...@@ -83,7 +81,10 @@ export default {
methods: { methods: {
onInput() { onInput() {
const { value } = this.$refs.input; const { value } = this.$refs.input;
eventHub.$emit('addIssuableFormInput', value, $(this.$refs.input).caret('pos')); this.$emit('addIssuableFormInput', {
newValue: value,
caretPos: $(this.$refs.input).caret('pos'),
});
}, },
onFocus() { onFocus() {
this.isInputFocused = true; this.isInputFocused = true;
...@@ -94,7 +95,7 @@ export default { ...@@ -94,7 +95,7 @@ export default {
// Avoid tokenizing partial input when clicking an autocomplete item // Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) { if (!this.isAutoCompleteOpen) {
const { value } = this.$refs.input; const { value } = this.$refs.input;
eventHub.$emit('addIssuableFormBlur', value); this.$emit('addIssuableFormBlur', value);
} }
}, },
onAutoCompleteToggled(isOpen) { onAutoCompleteToggled(isOpen) {
...@@ -103,12 +104,15 @@ export default { ...@@ -103,12 +104,15 @@ export default {
onInputWrapperClick() { onInputWrapperClick() {
this.$refs.input.focus(); this.$refs.input.focus();
}, },
onPendingIssuableRemoveRequest(params) {
this.$emit('pendingIssuableRemoveRequest', params);
},
onFormSubmit() { onFormSubmit() {
const { value } = this.$refs.input; const { value } = this.$refs.input;
eventHub.$emit('addIssuableFormSubmit', value); this.$emit('addIssuableFormSubmit', value);
}, },
onFormCancel() { onFormCancel() {
eventHub.$emit('addIssuableFormCancel'); this.$emit('addIssuableFormCancel');
}, },
}, },
}; };
...@@ -141,6 +145,7 @@ export default { ...@@ -141,6 +145,7 @@ export default {
:is-condensed="true" :is-condensed="true"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable" event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
/> />
</li> </li>
<li class="add-issuable-form-input-list-item"> <li class="add-issuable-form-input-list-item">
......
...@@ -89,7 +89,7 @@ export default { ...@@ -89,7 +89,7 @@ export default {
</div> </div>
<div class="item-meta-child d-flex align-items-center"> <div class="item-meta-child d-flex align-items-center">
<issue-milestone <issue-milestone
v-if="milestone" v-if="hasMilestone"
:milestone="milestone" :milestone="milestone"
class="d-flex align-items-center item-milestone" class="d-flex align-items-center item-milestone"
/> />
......
...@@ -4,7 +4,6 @@ import tooltip from '~/vue_shared/directives/tooltip'; ...@@ -4,7 +4,6 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import sortableConfig from 'ee/sortable/sortable_config'; import sortableConfig from 'ee/sortable/sortable_config';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import issueItem from './issue_item.vue'; import issueItem from './issue_item.vue';
import addIssuableForm from './add_issuable_form.vue'; import addIssuableForm from './add_issuable_form.vue';
...@@ -62,8 +61,7 @@ export default { ...@@ -62,8 +61,7 @@ export default {
}, },
pathIdSeparator: { pathIdSeparator: {
type: String, type: String,
required: false, required: true,
default: '#',
}, },
helpPath: { helpPath: {
type: String, type: String,
...@@ -110,9 +108,6 @@ export default { ...@@ -110,9 +108,6 @@ export default {
} }
}, },
methods: { methods: {
toggleAddRelatedIssuesForm() {
eventHub.$emit('toggleAddRelatedIssuesForm');
},
getBeforeAfterId(itemEl) { getBeforeAfterId(itemEl) {
const prevItemEl = itemEl.previousElementSibling; const prevItemEl = itemEl.previousElementSibling;
const nextItemEl = itemEl.nextElementSibling; const nextItemEl = itemEl.nextElementSibling;
...@@ -172,7 +167,7 @@ export default { ...@@ -172,7 +167,7 @@ export default {
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button" class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
aria-label="Add an issue" aria-label="Add an issue"
data-placement="top" data-placement="top"
@click="toggleAddRelatedIssuesForm" @click="$emit('toggleAddRelatedIssuesForm', $event);"
> >
<i class="fa fa-plus" aria-hidden="true"></i> <i class="fa fa-plus" aria-hidden="true"></i>
</button> </button>
...@@ -192,6 +187,11 @@ export default { ...@@ -192,6 +187,11 @@ export default {
:pending-references="pendingReferences" :pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
@pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event);"
@addIssuableFormInput="$emit('addIssuableFormInput', $event);"
@addIssuableFormBlur="$emit('addIssuableFormBlur', $event);"
@addIssuableFormSubmit="$emit('addIssuableFormSubmit', $event);"
@addIssuableFormCancel="$emit('addIssuableFormCancel', $event);"
/> />
</div> </div>
<div <div
...@@ -238,6 +238,7 @@ export default { ...@@ -238,6 +238,7 @@ export default {
:can-reorder="canReorder" :can-reorder="canReorder"
:path-id-separator="pathIdSeparator" :path-id-separator="pathIdSeparator"
event-namespace="relatedIssue" event-namespace="relatedIssue"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event);"
/> />
</li> </li>
</ul> </ul>
......
...@@ -25,7 +25,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways: ...@@ -25,7 +25,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/ */
import _ from 'underscore'; import _ from 'underscore';
import Flash from '~/flash'; import Flash from '~/flash';
import eventHub from '../event_hub';
import RelatedIssuesBlock from './related_issues_block.vue'; import RelatedIssuesBlock from './related_issues_block.vue';
import RelatedIssuesStore from '../stores/related_issues_store'; import RelatedIssuesStore from '../stores/related_issues_store';
import RelatedIssuesService from '../services/related_issues_service'; import RelatedIssuesService from '../services/related_issues_service';
...@@ -67,6 +66,16 @@ export default { ...@@ -67,6 +66,16 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
cssClass: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
this.store = new RelatedIssuesStore(); this.store = new RelatedIssuesStore();
...@@ -86,26 +95,9 @@ export default { ...@@ -86,26 +95,9 @@ export default {
}, },
}, },
created() { created() {
eventHub.$on('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$on('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$on('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$on('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$on('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$on('addIssuableFormInput', this.onInput);
eventHub.$on('addIssuableFormBlur', this.onBlur);
this.service = new RelatedIssuesService(this.endpoint); this.service = new RelatedIssuesService(this.endpoint);
this.fetchRelatedIssues(); this.fetchRelatedIssues();
}, },
beforeDestroy() {
eventHub.$off('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$off('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$off('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$off('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$off('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$off('addIssuableFormInput', this.onInput);
eventHub.$off('addIssuableFormBlur', this.onBlur);
},
methods: { methods: {
onRelatedIssueRemoveRequest(idToRemove) { onRelatedIssueRemoveRequest(idToRemove) {
const issueToRemove = _.find(this.state.relatedIssues, issue => issue.id === idToRemove); const issueToRemove = _.find(this.state.relatedIssues, issue => issue.id === idToRemove);
...@@ -198,7 +190,7 @@ export default { ...@@ -198,7 +190,7 @@ export default {
}); });
} }
}, },
onInput(newValue, caretPos) { onInput({ newValue, caretPos }) {
const rawReferences = newValue.split(/\s/); const rawReferences = newValue.split(/\s/);
let touchedReference; let touchedReference;
...@@ -235,6 +227,7 @@ export default { ...@@ -235,6 +227,7 @@ export default {
<template> <template>
<related-issues-block <related-issues-block
:class="cssClass"
:help-path="helpPath" :help-path="helpPath"
:is-fetching="isFetching" :is-fetching="isFetching"
:is-submitting="isSubmitting" :is-submitting="isSubmitting"
...@@ -246,7 +239,14 @@ export default { ...@@ -246,7 +239,14 @@ export default {
:input-value="inputValue" :input-value="inputValue"
:auto-complete-sources="autoCompleteSources" :auto-complete-sources="autoCompleteSources"
:title="title" :title="title"
path-id-separator="#" :path-id-separator="pathIdSeparator"
@saveReorder="saveIssueOrder" @saveReorder="saveIssueOrder"
@toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm"
@addIssuableFormInput="onInput"
@addIssuableFormBlur="onBlur"
@addIssuableFormSubmit="onPendingFormSubmit"
@addIssuableFormCancel="onPendingFormCancel"
@pendingIssuableRemoveRequest="onPendingIssueRemoveRequest"
@relatedIssueRemoveRequest="onRelatedIssueRemoveRequest"
/> />
</template> </template>
import Vue from 'vue';
export default new Vue();
import _ from 'underscore';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
const mixins = { const mixins = {
data() { data() {
...@@ -104,6 +104,9 @@ const mixins = { ...@@ -104,6 +104,9 @@ const mixins = {
hasTitle() { hasTitle() {
return this.title.length > 0; return this.title.length > 0;
}, },
hasMilestone() {
return !_.isEmpty(this.milestone);
},
iconName() { iconName() {
return this.isOpen ? 'issue-open-m' : 'issue-close'; return this.isOpen ? 'issue-open-m' : 'issue-close';
}, },
...@@ -139,10 +142,10 @@ const mixins = { ...@@ -139,10 +142,10 @@ const mixins = {
onRemoveRequest() { onRemoveRequest() {
let namespacePrefix = ''; let namespacePrefix = '';
if (this.eventNamespace && this.eventNamespace.length > 0) { if (this.eventNamespace && this.eventNamespace.length > 0) {
namespacePrefix = `${this.eventNamespace}-`; namespacePrefix = `${this.eventNamespace}`;
} }
eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey); this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey);
this.removeDisabled = true; this.removeDisabled = true;
}, },
......
...@@ -13,29 +13,44 @@ export default { ...@@ -13,29 +13,44 @@ export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
}, },
props: {
blockTitle: {
type: String,
required: false,
default: __('Epic'),
},
initialEpic: {
type: Object,
required: false,
default: () => null,
},
},
data() { data() {
return { return {
store: new Store(), store: !this.initialEpic ? new Store() : {},
}; };
}, },
computed: { computed: {
isLoading() { isLoading() {
return this.store.isFetching.epic; return this.initialEpic ? false : this.store.isFetching.epic;
},
epic() {
return this.initialEpic || this.store.epic;
}, },
epicIcon() { epicIcon() {
return spriteIcon('epic'); return spriteIcon('epic');
}, },
epicUrl() { epicUrl() {
return this.store.epic.url; return this.epic.url;
}, },
epicTitle() { epicTitle() {
return this.store.epic.title; return this.epic.title;
}, },
hasEpic() { hasEpic() {
return this.epicUrl && this.epicTitle; return this.epicUrl && this.epicTitle;
}, },
collapsedTitle() { collapsedTitle() {
return this.hasEpic ? this.epicTitle : 'None'; return this.hasEpic ? this.epicTitle : __('None');
}, },
tooltipTitle() { tooltipTitle() {
if (!this.hasEpic) { if (!this.hasEpic) {
...@@ -43,13 +58,13 @@ export default { ...@@ -43,13 +58,13 @@ export default {
} }
let tooltipTitle = this.epicTitle; let tooltipTitle = this.epicTitle;
if (this.store.epic.human_readable_end_date || this.store.epic.human_readable_timestamp) { if (this.epic.human_readable_end_date || this.epic.human_readable_timestamp) {
tooltipTitle += '<br />'; tooltipTitle += '<br />';
tooltipTitle += this.store.epic.human_readable_end_date tooltipTitle += this.epic.human_readable_end_date
? `${this.store.epic.human_readable_end_date} ` ? `${this.epic.human_readable_end_date} `
: ''; : '';
tooltipTitle += this.store.epic.human_readable_timestamp tooltipTitle += this.epic.human_readable_timestamp
? `(${this.store.epic.human_readable_timestamp})` ? `(${this.epic.human_readable_timestamp})`
: ''; : '';
} }
...@@ -71,15 +86,15 @@ export default { ...@@ -71,15 +86,15 @@ export default {
data-boundary="viewport" data-boundary="viewport"
> >
<div v-html="epicIcon"></div> <div v-html="epicIcon"></div>
<span v-if="!isLoading" class="collapse-truncated-title"> {{ collapsedTitle }} </span> <span v-if="!isLoading" class="collapse-truncated-title">{{ collapsedTitle }}</span>
</div> </div>
<div class="title hide-collapsed"> <div class="title hide-collapsed">
Epic {{ blockTitle }}
<gl-loading-icon v-if="isLoading" :inline="true" /> <gl-loading-icon v-if="isLoading" :inline="true" />
</div> </div>
<div v-if="!isLoading" class="value hide-collapsed"> <div v-if="!isLoading" class="value hide-collapsed">
<a v-if="hasEpic" :href="epicUrl" class="bold"> {{ epicTitle }} </a> <a v-if="hasEpic" :href="epicUrl" class="bold">{{ epicTitle }}</a>
<span v-else class="no-value"> None </span> <span v-else class="no-value">{{ __('None') }}</span>
</div> </div>
</div> </div>
</template> </template>
# frozen_string_literal: true
class Groups::EpicLinksController < Groups::EpicsController
include EpicRelations
before_action :check_feature_flag!
before_action :check_nested_support!
before_action do
push_frontend_feature_flag(:epic_links)
end
def destroy
result = ::Epics::UpdateService.new(group, current_user, { parent: nil }).execute(child_epic)
render json: { issuables: issuables }, status: result[:http_status]
end
private
def create_service
EpicLinks::CreateService.new(epic, current_user, create_params)
end
def list_service
EpicLinks::ListService.new(epic, current_user)
end
def child_epic
@child_epic ||= Epic.find(params[:id])
end
def check_feature_flag!
render_404 unless Feature.enabled?(:epic_links, group)
end
def check_nested_support!
render_404 unless Epic.supports_nested_objects?
end
end
...@@ -8,6 +8,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -8,6 +8,10 @@ class Groups::EpicsController < Groups::ApplicationController
include RendersNotes include RendersNotes
include EpicsActions include EpicsActions
before_action do
push_frontend_feature_flag(:epic_links)
end
before_action :check_epics_available! before_action :check_epics_available!
before_action :epic, except: [:index, :create] before_action :epic, except: [:index, :create]
before_action :set_issuables_index, only: :index before_action :set_issuables_index, only: :index
......
...@@ -30,6 +30,7 @@ class EpicsFinder < IssuableFinder ...@@ -30,6 +30,7 @@ class EpicsFinder < IssuableFinder
items = by_timeframe(items) items = by_timeframe(items)
items = by_state(items) items = by_state(items)
items = by_label(items) items = by_label(items)
items = by_parent(items)
sort(items) sort(items)
end end
...@@ -89,4 +90,16 @@ class EpicsFinder < IssuableFinder ...@@ -89,4 +90,16 @@ class EpicsFinder < IssuableFinder
items items
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def parent_id?
params[:parent_id].present?
end
# rubocop: disable CodeReuse/ActiveRecord
def by_parent(items)
return items unless parent_id?
items.where(parent_id: params[:parent_id])
end
# rubocop: enable CodeReuse/ActiveRecord
end end
...@@ -108,10 +108,10 @@ module Geo ...@@ -108,10 +108,10 @@ module Geo
def group_uploads def group_uploads
namespace_ids = namespace_ids =
if current_node.selective_sync_by_namespaces? if current_node.selective_sync_by_namespaces?
Gitlab::GroupHierarchy.new(current_node.namespaces).base_and_descendants.select(:id) Gitlab::ObjectHierarchy.new(current_node.namespaces).base_and_descendants.select(:id)
elsif current_node.selective_sync_by_shards? elsif current_node.selective_sync_by_shards?
leaf_groups = Namespace.where(id: current_node.projects.select(:namespace_id)) leaf_groups = Namespace.where(id: current_node.projects.select(:namespace_id))
Gitlab::GroupHierarchy.new(leaf_groups).base_and_ancestors.select(:id) Gitlab::ObjectHierarchy.new(leaf_groups).base_and_ancestors.select(:id)
else else
Namespace.none Namespace.none
end end
......
...@@ -20,6 +20,8 @@ module EE ...@@ -20,6 +20,8 @@ module EE
if parent.is_a?(Group) if parent.is_a?(Group)
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable) data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
data[:epicLinksEndpoint] = group_epic_links_path(parent, issuable)
data[:subepicsSupported] = ::Epic.supports_nested_objects?
end end
data data
......
...@@ -9,6 +9,7 @@ module EpicsHelper ...@@ -9,6 +9,7 @@ module EpicsHelper
epic_id: epic.id, epic_id: epic.id,
created: epic.created_at, created: epic.created_at,
author: epic_author(epic, opts), author: epic_author(epic, opts),
parent: epic_parent(epic.parent),
todo_exists: todo.present?, todo_exists: todo.present?,
todo_path: group_todos_path(group), todo_path: group_todos_path(group),
start_date: epic.start_date, start_date: epic.start_date,
...@@ -67,6 +68,16 @@ module EpicsHelper ...@@ -67,6 +68,16 @@ module EpicsHelper
} }
end end
def epic_parent(epic)
return unless epic
{
id: epic.id,
title: epic.title,
url: epic_path(epic)
}
end
def epic_endpoint_query_params(opts) def epic_endpoint_query_params(opts)
opts[:data] ||= {} opts[:data] ||= {}
opts[:data][:endpoint_query_params] = { opts[:data][:endpoint_query_params] = {
......
...@@ -12,6 +12,7 @@ module EE ...@@ -12,6 +12,7 @@ module EE
include Referable include Referable
include Awardable include Awardable
include LabelEventable include LabelEventable
include Descendant
enum state: { opened: 1, closed: 2 } enum state: { opened: 1, closed: 2 }
...@@ -33,6 +34,8 @@ module EE ...@@ -33,6 +34,8 @@ module EE
belongs_to :group belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone' belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :due_date_sourcing_milestone, class_name: 'Milestone' belongs_to :due_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :parent, class_name: "Epic"
has_many :children, class_name: "Epic", foreign_key: :parent_id
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) } has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) }
...@@ -215,6 +218,20 @@ module EE ...@@ -215,6 +218,20 @@ module EE
from && from != group from && from != group
end end
def ancestors
return self.class.none unless parent_id
hierarchy.ancestors
end
def descendants
hierarchy.descendants
end
def hierarchy
::Gitlab::ObjectHierarchy.new(self.class.where(id: id))
end
# we don't support project epics for epics yet, planned in the future #4019 # we don't support project epics for epics yet, planned in the future #4019
def update_project_counter_caches def update_project_counter_caches
end end
......
...@@ -186,7 +186,7 @@ module EE ...@@ -186,7 +186,7 @@ module EE
project_creation_levels << nil project_creation_levels << nil
end end
developer_groups_hierarchy = ::Gitlab::GroupHierarchy.new(developer_groups).base_and_descendants developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
::Group.where(id: developer_groups_hierarchy.select(:id), ::Group.where(id: developer_groups_hierarchy.select(:id),
project_creation_level: project_creation_levels) project_creation_level: project_creation_levels)
end end
......
...@@ -187,7 +187,7 @@ class GeoNode < ActiveRecord::Base ...@@ -187,7 +187,7 @@ class GeoNode < ActiveRecord::Base
return Project.all unless selective_sync? return Project.all unless selective_sync?
if selective_sync_by_namespaces? if selective_sync_by_namespaces?
query = Gitlab::GroupHierarchy.new(namespaces).base_and_descendants query = Gitlab::ObjectHierarchy.new(namespaces).base_and_descendants
Project.where(namespace_id: query.select(:id)) Project.where(namespace_id: query.select(:id))
elsif selective_sync_by_shards? elsif selective_sync_by_shards?
Project.where(repository_storage: selective_sync_shards) Project.where(repository_storage: selective_sync_shards)
......
...@@ -52,7 +52,7 @@ module EE ...@@ -52,7 +52,7 @@ module EE
namespaces = ::Namespace.reorder(nil).where('namespaces.id = projects.namespace_id') namespaces = ::Namespace.reorder(nil).where('namespaces.id = projects.namespace_id')
if ::Feature.enabled?(:shared_runner_minutes_on_root_namespace) if ::Feature.enabled?(:shared_runner_minutes_on_root_namespace)
namespaces = ::Gitlab::GroupHierarchy.new(namespaces).roots namespaces = ::Gitlab::ObjectHierarchy.new(namespaces).roots
end end
namespaces namespaces
......
# frozen_string_literal: true
module EpicLinks
class CreateService < IssuableLinks::CreateService
def execute
return error('Epic hierarchy level too deep', 409) if parent_ancestors_count >= 4
super
end
private
def relate_issuables(referenced_epic)
affected_epics = [issuable]
affected_epics << referenced_epic if referenced_epic.parent
referenced_epic.update(parent: issuable)
affected_epics.each(&:update_start_and_due_dates)
end
def linkable_issuables(epics)
@linkable_issuables ||= begin
return [] unless can?(current_user, :admin_epic, issuable.group)
epics.select do |epic|
issuable_group_descendants.include?(epic.group) &&
!previous_related_issuables.include?(epic) &&
!level_depth_exceeded?(epic)
end
end
end
def references(extractor)
extractor.epics
end
def extractor_context
{ group: issuable.group }
end
def previous_related_issuables
issuable.children.to_a
end
def issuable_group_descendants
@descendants ||= issuable.group.self_and_descendants
end
def level_depth_exceeded?(epic)
depth_level(epic) + parent_ancestors_count >= 5
end
def depth_level(epic)
epic.descendants.count + 1 # level including epic -> therefore +1
end
def parent_ancestors_count
@parent_ancestors_count ||= issuable.ancestors.count
end
def issuables_assigned_message
'Epic(s) already assigned'
end
def issuables_not_found_message
'No Epic found for given params'
end
end
end
# frozen_string_literal: true
module EpicLinks
class ListService < IssuableLinks::ListService
private
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
EpicsFinder.new(current_user, parent_id: issuable.id, group_id: issuable.group.id).execute
end
def reference(epic)
epic.to_reference(issuable.group)
end
def issuable_path(epic)
group_epic_path(epic.group, epic)
end
def relation_path(epic)
group_epic_link_path(epic.group, issuable.iid, epic.id)
end
def to_hash(object)
{
id: object.id,
title: object.title,
state: object.state,
reference: reference(object),
path: issuable_path(object),
relation_path: relation_path(object)
}
end
end
end
...@@ -57,6 +57,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -57,6 +57,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues' resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues'
resources :epic_links, only: [:index, :create, :destroy, :update], as: 'links', path: 'links'
scope module: :epics do scope module: :epics do
resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ }
......
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddParentToEpic < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
add_column :epics, :parent_id, :integer
end
def down
remove_column :epics, :parent_id
end
end
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddParentEpicFk < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :epics, :epics, column: :parent_id, on_delete: :cascade
add_concurrent_index :epics, :parent_id
end
def down
remove_foreign_key :epics, column: :parent_id
remove_concurrent_index :epics, :parent_id
end
end
...@@ -58,7 +58,7 @@ module Gitlab ...@@ -58,7 +58,7 @@ module Gitlab
# Returns an ActiveRecord::Relation that includes the given groups, and all # Returns an ActiveRecord::Relation that includes the given groups, and all
# their (recursive) ancestors. # their (recursive) ancestors.
def groups_and_ancestors_for(groups) def groups_and_ancestors_for(groups)
Gitlab::GroupHierarchy Gitlab::ObjectHierarchy
.new(groups) .new(groups)
.base_and_ancestors .base_and_ancestors
.select(:id, :parent_id, :plan_id) .select(:id, :parent_id, :plan_id)
......
# frozen_string_literal: true
require 'spec_helper'
describe Groups::EpicLinksController, :postgresql do
let(:group) { create(:group, :public) }
let(:parent_epic) { create(:epic, group: group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group) }
let(:user) { create(:user) }
before do
sign_in(user)
end
shared_examples 'unlicensed epics action' do
before do
stub_licensed_features(epics: false)
group.add_developer(user)
subject
end
it 'returns 400 status' do
expect(response).to have_gitlab_http_status(404)
end
end
shared_examples 'feature flag disabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_links: false)
group.add_developer(user)
subject
end
it 'returns 400 status' do
expect(response).to have_gitlab_http_status(404)
end
end
describe 'GET #index' do
before do
epic1.update(parent: parent_epic)
end
subject { get :index, group_id: group, epic_id: parent_epic.to_param }
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
group.add_developer(user)
subject
end
it 'returns the correct JSON response' do
list_service_response = EpicLinks::ListService.new(parent_epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq(list_service_response.as_json)
end
end
end
describe 'POST #create' do
subject do
reference = [epic1.to_reference(full: true)]
post :create, group_id: group, epic_id: parent_epic.to_param, issuable_references: reference
end
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
end
context 'when user has permissions to create requested association' do
before do
group.add_developer(user)
end
it 'returns correct response for the correct issue reference' do
subject
list_service_response = EpicLinks::ListService.new(parent_epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issuables' => list_service_response.as_json)
end
it 'updates a parent for the referenced epic' do
expect { subject }.to change { epic1.reload.parent }.from(nil).to(parent_epic)
end
end
context 'when user does not have permissions to create requested association' do
it 'returns 403 status' do
subject
expect(response).to have_gitlab_http_status(403)
end
it 'does not update parent attribute' do
expect { subject }.not_to change { epic1.reload.parent }.from(nil)
end
end
end
end
describe 'DELETE #destroy' do
before do
epic1.update(parent: parent_epic)
end
subject { delete :destroy, group_id: group, epic_id: parent_epic.to_param, id: epic1.id }
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
end
context 'when user has permissions to update the parent epic' do
before do
group.add_developer(user)
end
it 'returns status 200' do
subject
expect(response.status).to eq(200)
end
it 'destroys the link' do
expect { subject }.to change { epic1.reload.parent }.from(parent_epic).to(nil)
end
end
context 'when user does not have permissions to update the parent epic' do
it 'returns status 404' do
subject
expect(response.status).to eq(403)
end
it 'does not destroy the link' do
expect { subject }.not_to change { epic1.reload.parent }.from(parent_epic)
end
end
context 'when the epic does not have any parent' do
it 'returns status 404' do
delete :destroy, group_id: group, epic_id: parent_epic.to_param, id: epic2.id
expect(response.status).to eq(403)
end
end
end
end
end
...@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do ...@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do
end end
it 'user can see issues from public project but cannot delete the associations' do it 'user can see issues from public project but cannot delete the associations' do
within('.related-issues-block ul.related-items-list') do within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li', count: 1)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button') expect(page).not_to have_selector('button.js-issue-item-remove-button')
...@@ -40,11 +40,11 @@ describe 'Epic Issues', :js do ...@@ -40,11 +40,11 @@ describe 'Epic Issues', :js do
end end
it 'user cannot add new issues to the epic' do it 'user cannot add new issues to the epic' do
expect(page).not_to have_selector('.related-issues-block h3.card-title button') expect(page).not_to have_selector('.js-related-issues-block h3.card-title button')
end end
it 'user cannot reorder issues in epic' do it 'user cannot reorder issues in epic' do
expect(page).not_to have_selector('.js-related-issues-token-list-item.user-can-drag') expect(page).not_to have_selector('.js-related-issues-block .js-related-issues-token-list-item.user-can-drag')
end end
end end
...@@ -53,13 +53,13 @@ describe 'Epic Issues', :js do ...@@ -53,13 +53,13 @@ describe 'Epic Issues', :js do
let(:issue_invalid) { create(:issue) } let(:issue_invalid) { create(:issue) }
def add_issues(references) def add_issues(references)
find('.related-issues-block h3.card-title button').click find('.js-related-issues-block h3.card-title button').click
find('.js-add-issuable-form-input').set(references) find('.js-related-issues-block .js-add-issuable-form-input').set(references)
# When adding long references, for some reason the input gets stuck # When adding long references, for some reason the input gets stuck
# waiting for more text. Send a keystroke before clicking the button to # waiting for more text. Send a keystroke before clicking the button to
# get out of this mode. # get out of this mode.
find('.js-add-issuable-form-input').send_keys(:tab) find('.js-related-issues-block .js-add-issuable-form-input').send_keys(:tab)
find('.js-add-issuable-form-add-button').click find('.js-related-issues-block .js-add-issuable-form-add-button').click
wait_for_requests wait_for_requests
end end
...@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do ...@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end end
it 'user can see all issues of the group and delete the associations' do it 'user can see all issues of the group and delete the associations' do
within('.related-issues-block ul.related-items-list') do within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 2) expect(page).to have_selector('li', count: 2)
expect(page).to have_content(public_issue.title) expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title) expect(page).to have_content(private_issue.title)
...@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do ...@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
wait_for_requests wait_for_requests
within('.related-issues-block ul.related-items-list') do within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1) expect(page).to have_selector('li', count: 1)
end end
end end
...@@ -100,20 +100,20 @@ describe 'Epic Issues', :js do ...@@ -100,20 +100,20 @@ describe 'Epic Issues', :js do
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text') expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Issue found for given params') expect(page).not_to have_content('No Issue found for given params')
within('.related-issues-block ul.related-items-list') do within('.js-related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 3) expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title) expect(page).to have_content(issue_to_add.title)
end end
end end
it 'user can reorder issues in epic' do it 'user can reorder issues in epic' do
expect(first('.js-related-issues-token-list-item')).to have_content(public_issue.title) expect(first('.js-related-issues-block .js-related-issues-token-list-item')).to have_content(public_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(private_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: '.related-items-list', to_index: 1) drag_to(selector: '.js-related-issues-block .related-items-list', to_index: 1)
expect(first('.js-related-issues-token-list-item')).to have_content(private_issue.title) expect(first('.js-related-issues-block .js-related-issues-token-list-item')).to have_content(private_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title) expect(page.all('.js-related-issues-block .js-related-issues-token-list-item').last).to have_content(public_issue.title)
end end
end end
end end
...@@ -28,6 +28,10 @@ describe 'Epic in issue sidebar', :js do ...@@ -28,6 +28,10 @@ describe 'Epic in issue sidebar', :js do
context 'when epics available' do context 'when epics available' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end end
it_behaves_like 'epic in issue sidebar' it_behaves_like 'epic in issue sidebar'
......
...@@ -169,6 +169,21 @@ describe EpicsFinder do ...@@ -169,6 +169,21 @@ describe EpicsFinder do
expect(epics(params)).to contain_exactly(epic3) expect(epics(params)).to contain_exactly(epic3)
end end
end end
context 'by parent' do
before do
epic2.update(parent: epic1)
epic3.update(parent: epic2)
end
it 'returns direct children of the parent' do
params = {
parent_id: epic1.id
}
expect(epics(params)).to contain_exactly(epic2)
end
end
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe IssuablesHelper do
describe '#issuable_initial_data' do
it 'returns the correct data for an epic' do
user = create(:user)
epic = create(:epic, author: user, description: 'epic text')
@group = epic.group
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).and_return(true)
expected_data = {
endpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
epicLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/links",
updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
canUpdate: true,
canDestroy: true,
canAdmin: true,
issuableRef: "&#{epic.iid}",
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION,
issuableTemplates: nil,
groupPath: @group.path,
initialTitleHtml: epic.title,
initialTitleText: epic.title,
initialDescriptionHtml: '<p dir="auto">epic text</p>',
initialDescriptionText: 'epic text',
initialTaskStatus: '0 of 0 tasks completed',
subepicsSupported: Gitlab::Database.postgresql?
}
expect(helper.issuable_initial_data(epic)).to eq(expected_data)
end
end
end
...@@ -5,16 +5,20 @@ describe EpicsHelper do ...@@ -5,16 +5,20 @@ describe EpicsHelper do
describe '#epic_show_app_data' do describe '#epic_show_app_data' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group) }
let(:milestone1) { create(:milestone, title: 'make me a sandwich', start_date: '2010-01-01', due_date: '2019-12-31') } let(:milestone1) { create(:milestone, title: 'make me a sandwich', start_date: '2010-01-01', due_date: '2019-12-31') }
let(:milestone2) { create(:milestone, title: 'make me a pizza', start_date: '2020-01-01', due_date: '2029-12-31') } let(:milestone2) { create(:milestone, title: 'make me a pizza', start_date: '2020-01-01', due_date: '2029-12-31') }
let(:parent_epic) { create(:epic, group: group) }
let!(:epic) do let!(:epic) do
create( create(
:epic, :epic,
group: group,
author: user, author: user,
start_date_sourcing_milestone: milestone1, start_date_sourcing_milestone: milestone1,
start_date: Date.new(2000, 1, 1), start_date: Date.new(2000, 1, 1),
due_date_sourcing_milestone: milestone2, due_date_sourcing_milestone: milestone2,
due_date: Date.new(2000, 1, 2) due_date: Date.new(2000, 1, 2),
parent: parent_epic
) )
end end
...@@ -30,7 +34,7 @@ describe EpicsHelper do ...@@ -30,7 +34,7 @@ describe EpicsHelper do
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url) expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys) expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[ expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date epic_id created author todo_exists todo_path start_date parent
start_date_is_fixed start_date_fixed start_date_from_milestones start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
...@@ -43,6 +47,11 @@ describe EpicsHelper do ...@@ -43,6 +47,11 @@ describe EpicsHelper do
'username' => "@#{user.username}", 'username' => "@#{user.username}",
'src' => 'icon_path' 'src' => 'icon_path'
}) })
expect(meta_data['parent']).to eq({
'id' => parent_epic.id,
'title' => parent_epic.title,
'url' => "/groups/#{group.full_path}/-/epics/#{parent_epic.iid}"
})
expect(meta_data['start_date']).to eq('2000-01-01') expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title) expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title)
expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s) expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s)
...@@ -76,7 +85,7 @@ describe EpicsHelper do ...@@ -76,7 +85,7 @@ describe EpicsHelper do
meta_data = JSON.parse(data[:meta]) meta_data = JSON.parse(data[:meta])
expect(meta_data.keys).to match_array(%w[ expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date epic_id created author todo_exists todo_path start_date parent
start_date_is_fixed start_date_fixed start_date_from_milestones start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
......
...@@ -40,6 +40,7 @@ export const contentProps = { ...@@ -40,6 +40,7 @@ export const contentProps = {
markdownPreviewPath: '', markdownPreviewPath: '',
markdownDocsPath: '', markdownDocsPath: '',
issueLinksEndpoint: '/', issueLinksEndpoint: '/',
epicLinksEndpoint: '/',
groupPath: '', groupPath: '',
namespace: 'gitlab-org', namespace: 'gitlab-org',
labelsPath: '', labelsPath: '',
...@@ -71,6 +72,15 @@ export const contentProps = { ...@@ -71,6 +72,15 @@ export const contentProps = {
subscribed: true, subscribed: true,
todoExists: false, todoExists: false,
state: 'opened', state: 'opened',
parent: {
id: 12,
startDateIsFixed: true,
startDate: '2018-12-01',
dueDateIsFixed: true,
dueDateFixed: '2019-12-31',
title: 'Sample Parent Epic',
url: `${gl.TEST_HOST}/groups/gitlab-org/-/epics/12`,
},
}; };
export const headerProps = { export const headerProps = {
......
...@@ -19,6 +19,7 @@ describe('epicSidebar', () => { ...@@ -19,6 +19,7 @@ describe('epicSidebar', () => {
labelsWebUrl, labelsWebUrl,
epicsWebUrl, epicsWebUrl,
labels, labels,
parent,
participants, participants,
subscribed, subscribed,
toggleSubscriptionPath, toggleSubscriptionPath,
...@@ -55,6 +56,7 @@ describe('epicSidebar', () => { ...@@ -55,6 +56,7 @@ describe('epicSidebar', () => {
startDateSourcingMilestoneDates, startDateSourcingMilestoneDates,
dueDateSourcingMilestoneTitle, dueDateSourcingMilestoneTitle,
dueDateSourcingMilestoneDates, dueDateSourcingMilestoneDates,
parent,
toggleSubscriptionPath, toggleSubscriptionPath,
labelsPath, labelsPath,
labelsWebUrl, labelsWebUrl,
...@@ -115,6 +117,47 @@ describe('epicSidebar', () => { ...@@ -115,6 +117,47 @@ describe('epicSidebar', () => {
).toEqual('Jan 1, 2018'); ).toEqual('Jan 1, 2018');
}); });
describe('parent epic', () => {
it('should render parent epic information in sidebar when `parent` is present', () => {
const parentEpicEl = vm.$el.querySelector('.block.parent-epic');
expect(parentEpicEl).not.toBeNull();
expect(parentEpicEl.querySelector('.collapse-truncated-title').innerText.trim()).toBe(
parent.title,
);
expect(parentEpicEl.querySelector('.value a').innerText.trim()).toBe(parent.title);
});
it('should render parent epic as `none` when `parent` is empty', done => {
vm.parent = {};
Vue.nextTick()
.then(() => {
const parentEpicEl = vm.$el.querySelector('.block.parent-epic');
expect(parentEpicEl.querySelector('.collapse-truncated-title').innerText.trim()).toBe(
'None',
);
expect(parentEpicEl.querySelector('.value .no-value').innerText.trim()).toBe('None');
})
.then(done)
.catch(done.fail);
});
it('should render parent epic information icon when sidebar is collapsed', () => {
const parentEpicElCollapsed = vm.$el.querySelector(
'.block.parent-epic .sidebar-collapsed-icon',
);
expect(parentEpicElCollapsed).not.toBeNull();
expect(parentEpicElCollapsed.querySelector('svg use').getAttribute('xlink:href')).toContain(
'epic',
);
});
});
describe('computed prop', () => { describe('computed prop', () => {
const getComponent = ( const getComponent = (
customPropsData = { customPropsData = {
......
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import addIssuableForm from 'ee/related_issues/components/add_issuable_form.vue'; import addIssuableForm from 'ee/related_issues/components/add_issuable_form.vue';
const issuable1 = { const issuable1 = {
...@@ -167,21 +166,7 @@ describe('AddIssuableForm', () => { ...@@ -167,21 +166,7 @@ describe('AddIssuableForm', () => {
}); });
describe('methods', () => { describe('methods', () => {
let addIssuableFormInputSpy;
let addIssuableFormBlurSpy;
let addIssuableFormSubmitSpy;
let addIssuableFormCancelSpy;
beforeEach(() => { beforeEach(() => {
addIssuableFormInputSpy = jasmine.createSpy('spy');
addIssuableFormBlurSpy = jasmine.createSpy('spy');
addIssuableFormSubmitSpy = jasmine.createSpy('spy');
addIssuableFormCancelSpy = jasmine.createSpy('spy');
eventHub.$on('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$on('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$on('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$on('addIssuableFormCancel', addIssuableFormCancelSpy);
const el = document.createElement('div'); const el = document.createElement('div');
// We need to append to body to get focus tests working // We need to append to body to get focus tests working
document.body.appendChild(el); document.body.appendChild(el);
...@@ -198,13 +183,6 @@ describe('AddIssuableForm', () => { ...@@ -198,13 +183,6 @@ describe('AddIssuableForm', () => {
}).$mount(el); }).$mount(el);
}); });
afterEach(() => {
eventHub.$off('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$off('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$off('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy);
});
it('when clicking somewhere on the input wrapper should focus the input', done => { it('when clicking somewhere on the input wrapper should focus the input', done => {
vm.onInputWrapperClick(); vm.onInputWrapperClick();
...@@ -219,18 +197,19 @@ describe('AddIssuableForm', () => { ...@@ -219,18 +197,19 @@ describe('AddIssuableForm', () => {
}); });
it('when filling in the input', () => { it('when filling in the input', () => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled(); spyOn(vm, '$emit');
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue; vm.$refs.input.value = newInputValue;
vm.onInput(); vm.onInput();
expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length); expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue,
caretPos: newInputValue.length,
});
}); });
it('when blurring the input', done => { it('when blurring the input', done => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled(); spyOn(vm, '$emit');
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue; vm.$refs.input.value = newInputValue;
vm.onBlur(); vm.onBlur();
...@@ -238,7 +217,7 @@ describe('AddIssuableForm', () => { ...@@ -238,7 +217,7 @@ describe('AddIssuableForm', () => {
setTimeout(() => { setTimeout(() => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false); expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false);
expect(addIssuableFormBlurSpy).toHaveBeenCalledWith(newInputValue); expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormBlur', newInputValue);
done(); done();
}); });
...@@ -266,29 +245,25 @@ describe('AddIssuableForm', () => { ...@@ -266,29 +245,25 @@ describe('AddIssuableForm', () => {
setTimeout(() => { setTimeout(() => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$refs.input.value).toEqual(''); expect(vm.$refs.input.value).toEqual('');
expect(addIssuableFormInputSpy.calls.count()).toEqual(1);
done(); done();
}); });
}); });
}); });
it('when submitting pending issues', () => { it('when submitting pending issues', () => {
expect(addIssuableFormSubmitSpy).not.toHaveBeenCalled(); spyOn(vm, '$emit');
const newInputValue = 'filling in things'; const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue; vm.$refs.input.value = newInputValue;
vm.onFormSubmit(); vm.onFormSubmit();
expect(addIssuableFormSubmitSpy).toHaveBeenCalledWith(newInputValue); expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', newInputValue);
}); });
it('when canceling form to collapse', () => { it('when canceling form to collapse', () => {
expect(addIssuableFormCancelSpy).not.toHaveBeenCalled(); spyOn(vm, '$emit');
vm.onFormCancel(); vm.onFormCancel();
expect(addIssuableFormCancelSpy).toHaveBeenCalled(); expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormCancel');
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import issueItem from 'ee/related_issues/components/issue_item.vue'; import issueItem from 'ee/related_issues/components/issue_item.vue';
import eventHub from 'ee/related_issues/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { defaultMilestone, defaultAssignees } from '../mock_data'; import { defaultMilestone, defaultAssignees } from '../mock_data';
...@@ -18,6 +17,7 @@ describe('issueItem', () => { ...@@ -18,6 +17,7 @@ describe('issueItem', () => {
createdAt: '2018-12-01T00:00:00.00Z', createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone, milestone: defaultMilestone,
assignees: defaultAssignees, assignees: defaultAssignees,
eventNamespace: 'relatedIssue',
}; };
beforeEach(() => { beforeEach(() => {
...@@ -172,11 +172,10 @@ describe('issueItem', () => { ...@@ -172,11 +172,10 @@ describe('issueItem', () => {
}); });
it('triggers onRemoveRequest when clicked', () => { it('triggers onRemoveRequest when clicked', () => {
const spy = jasmine.createSpy('spy'); spyOn(vm, '$emit');
eventHub.$on('removeRequest', spy);
removeBtn.click(); removeBtn.click();
expect(spy).toHaveBeenCalled(); expect(vm.$emit).toHaveBeenCalledWith('relatedIssueRemoveRequest', props.idKey);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import issueToken from 'ee/related_issues/components/issue_token.vue'; import issueToken from 'ee/related_issues/components/issue_token.vue';
describe('IssueToken', () => { describe('IssueToken', () => {
...@@ -7,6 +6,7 @@ describe('IssueToken', () => { ...@@ -7,6 +6,7 @@ describe('IssueToken', () => {
const displayReference = 'foo/bar#123'; const displayReference = 'foo/bar#123';
const title = 'some title'; const title = 'some title';
const pathIdSeparator = '#'; const pathIdSeparator = '#';
const eventNamespace = 'pendingIssuable';
let IssueToken; let IssueToken;
let vm; let vm;
...@@ -25,6 +25,7 @@ describe('IssueToken', () => { ...@@ -25,6 +25,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
}, },
...@@ -46,6 +47,7 @@ describe('IssueToken', () => { ...@@ -46,6 +47,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
title, title,
...@@ -65,6 +67,7 @@ describe('IssueToken', () => { ...@@ -65,6 +67,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
title, title,
...@@ -84,6 +87,7 @@ describe('IssueToken', () => { ...@@ -84,6 +87,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
state: 'opened', state: 'opened',
...@@ -101,6 +105,7 @@ describe('IssueToken', () => { ...@@ -101,6 +105,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
state: 'reopened', state: 'reopened',
...@@ -118,6 +123,7 @@ describe('IssueToken', () => { ...@@ -118,6 +123,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
state: 'closed', state: 'closed',
...@@ -137,6 +143,7 @@ describe('IssueToken', () => { ...@@ -137,6 +143,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
title, title,
...@@ -160,6 +167,7 @@ describe('IssueToken', () => { ...@@ -160,6 +167,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
}, },
...@@ -176,6 +184,7 @@ describe('IssueToken', () => { ...@@ -176,6 +184,7 @@ describe('IssueToken', () => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
canRemove: true, canRemove: true,
...@@ -190,29 +199,22 @@ describe('IssueToken', () => { ...@@ -190,29 +199,22 @@ describe('IssueToken', () => {
}); });
describe('methods', () => { describe('methods', () => {
let removeRequestSpy;
beforeEach(() => { beforeEach(() => {
vm = new IssueToken({ vm = new IssueToken({
propsData: { propsData: {
idKey, idKey,
eventNamespace,
displayReference, displayReference,
pathIdSeparator, pathIdSeparator,
}, },
}).$mount(); }).$mount();
removeRequestSpy = jasmine.createSpy('spy');
eventHub.$on('removeRequest', removeRequestSpy);
});
afterEach(() => {
eventHub.$off('removeRequest', removeRequestSpy);
}); });
it('when getting checked', () => { it('when getting checked', () => {
expect(removeRequestSpy).not.toHaveBeenCalled(); spyOn(vm, '$emit');
vm.onRemoveRequest(); vm.onRemoveRequest();
expect(removeRequestSpy).toHaveBeenCalled(); expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import relatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue'; import relatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuable1, issuable2, issuable3, issuable4, issuable5 } from '../mock_data'; import { issuable1, issuable2, issuable3, issuable4, issuable5 } from '../mock_data';
...@@ -20,7 +19,11 @@ describe('RelatedIssuesBlock', () => { ...@@ -20,7 +19,11 @@ describe('RelatedIssuesBlock', () => {
describe('with defaults', () => { describe('with defaults', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock().$mount(); vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
},
}).$mount();
}); });
it('unable to add new related issues', () => { it('unable to add new related issues', () => {
...@@ -40,6 +43,7 @@ describe('RelatedIssuesBlock', () => { ...@@ -40,6 +43,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
propsData: { propsData: {
pathIdSeparator: '#',
isFetching: true, isFetching: true,
}, },
}).$mount(); }).$mount();
...@@ -58,6 +62,7 @@ describe('RelatedIssuesBlock', () => { ...@@ -58,6 +62,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
propsData: { propsData: {
pathIdSeparator: '#',
canAdmin: true, canAdmin: true,
}, },
}).$mount(); }).$mount();
...@@ -72,6 +77,7 @@ describe('RelatedIssuesBlock', () => { ...@@ -72,6 +77,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
propsData: { propsData: {
pathIdSeparator: '#',
isFormVisible: true, isFormVisible: true,
}, },
}).$mount(); }).$mount();
...@@ -86,6 +92,7 @@ describe('RelatedIssuesBlock', () => { ...@@ -86,6 +92,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
propsData: { propsData: {
pathIdSeparator: '#',
relatedIssues: [issuable1, issuable2], relatedIssues: [issuable1, issuable2],
}, },
}).$mount(); }).$mount();
...@@ -97,20 +104,13 @@ describe('RelatedIssuesBlock', () => { ...@@ -97,20 +104,13 @@ describe('RelatedIssuesBlock', () => {
}); });
describe('methods', () => { describe('methods', () => {
let toggleAddRelatedIssuesFormSpy;
beforeEach(() => { beforeEach(() => {
vm = new RelatedIssuesBlock({ vm = new RelatedIssuesBlock({
propsData: { propsData: {
pathIdSeparator: '#',
relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5], relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
}, },
}).$mount(); }).$mount();
toggleAddRelatedIssuesFormSpy = jasmine.createSpy('spy');
eventHub.$on('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
});
afterEach(() => {
eventHub.$off('toggleAddRelatedIssuesForm', toggleAddRelatedIssuesFormSpy);
}); });
it('reorder item correctly when an item is moved to the top', () => { it('reorder item correctly when an item is moved to the top', () => {
...@@ -140,12 +140,5 @@ describe('RelatedIssuesBlock', () => { ...@@ -140,12 +140,5 @@ describe('RelatedIssuesBlock', () => {
expect(beforeAfterIds.beforeId).toBe(3); expect(beforeAfterIds.beforeId).toBe(3);
expect(beforeAfterIds.afterId).toBe(5); expect(beforeAfterIds.afterId).toBe(5);
}); });
it('when expanding add related issue form', () => {
expect(toggleAddRelatedIssuesFormSpy).not.toHaveBeenCalled();
vm.toggleAddRelatedIssuesForm();
expect(toggleAddRelatedIssuesFormSpy).toHaveBeenCalled();
});
}); });
}); });
...@@ -300,7 +300,10 @@ describe('RelatedIssuesRoot', () => { ...@@ -300,7 +300,10 @@ describe('RelatedIssuesRoot', () => {
it('fill in issue number reference and adds to pending related issues', () => { it('fill in issue number reference and adds to pending related issues', () => {
const input = '#123 '; const input = '#123 ';
vm.onInput(input, input.length); vm.onInput({
newValue: input,
caretPos: input.length,
});
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('#123'); expect(vm.state.pendingReferences[0]).toEqual('#123');
...@@ -308,7 +311,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -308,7 +311,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with full reference', () => { it('fill in with full reference', () => {
const input = 'asdf/qwer#444 '; const input = 'asdf/qwer#444 ';
vm.onInput(input, input.length); vm.onInput({ newValue: input, caretPos: input.length });
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
...@@ -317,7 +320,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -317,7 +320,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with issue link', () => { it('fill in with issue link', () => {
const link = 'http://localhost:3000/foo/bar/issues/111'; const link = 'http://localhost:3000/foo/bar/issues/111';
const input = `${link} `; const input = `${link} `;
vm.onInput(input, input.length); vm.onInput({ newValue: input, caretPos: input.length });
expect(vm.state.pendingReferences.length).toEqual(1); expect(vm.state.pendingReferences.length).toEqual(1);
expect(vm.state.pendingReferences[0]).toEqual(link); expect(vm.state.pendingReferences[0]).toEqual(link);
...@@ -325,7 +328,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -325,7 +328,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with multiple references', () => { it('fill in with multiple references', () => {
const input = 'asdf/qwer#444 #12 '; const input = 'asdf/qwer#444 #12 ';
vm.onInput(input, input.length); vm.onInput({ newValue: input, caretPos: input.length });
expect(vm.state.pendingReferences.length).toEqual(2); expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); expect(vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
...@@ -334,7 +337,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -334,7 +337,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with some invalid things', () => { it('fill in with some invalid things', () => {
const input = 'something random '; const input = 'something random ';
vm.onInput(input, input.length); vm.onInput({ newValue: input, caretPos: input.length });
expect(vm.state.pendingReferences.length).toEqual(2); expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('something'); expect(vm.state.pendingReferences[0]).toEqual('something');
...@@ -343,7 +346,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -343,7 +346,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in invalid and some legit references', () => { it('fill in invalid and some legit references', () => {
const input = 'something random #123 '; const input = 'something random #123 ';
vm.onInput(input, input.length); vm.onInput({ newValue: input, caretPos: input.length });
expect(vm.state.pendingReferences.length).toEqual(3); expect(vm.state.pendingReferences.length).toEqual(3);
expect(vm.state.pendingReferences[0]).toEqual('something'); expect(vm.state.pendingReferences[0]).toEqual('something');
...@@ -353,7 +356,7 @@ describe('RelatedIssuesRoot', () => { ...@@ -353,7 +356,7 @@ describe('RelatedIssuesRoot', () => {
it('keep reference piece in input while we are touching it', () => { it('keep reference piece in input while we are touching it', () => {
const input = 'a #123 b '; const input = 'a #123 b ';
vm.onInput(input, 3); vm.onInput({ newValue: input, caretPos: 3 });
expect(vm.state.pendingReferences.length).toEqual(2); expect(vm.state.pendingReferences.length).toEqual(2);
expect(vm.state.pendingReferences[0]).toEqual('a'); expect(vm.state.pendingReferences[0]).toEqual('a');
......
...@@ -16,7 +16,9 @@ describe('sidebarItemEpic', () => { ...@@ -16,7 +16,9 @@ describe('sidebarItemEpic', () => {
}); });
const SidebarItemEpic = Vue.extend(sidebarItemEpic); const SidebarItemEpic = Vue.extend(sidebarItemEpic);
vm = mountComponent(SidebarItemEpic, {}); vm = mountComponent(SidebarItemEpic, {
initialEpic: null,
});
}); });
afterEach(() => { afterEach(() => {
......
...@@ -7,7 +7,9 @@ describe Epic do ...@@ -7,7 +7,9 @@ describe Epic do
it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:assignee).class_name('User') } it { is_expected.to belong_to(:assignee).class_name('User') }
it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:parent) }
it { is_expected.to have_many(:epic_issues) } it { is_expected.to have_many(:epic_issues) }
it { is_expected.to have_many(:children) }
end end
describe 'validations' do describe 'validations' do
...@@ -77,6 +79,36 @@ describe Epic do ...@@ -77,6 +79,36 @@ describe Epic do
end end
end end
describe '#ancestors', :nested_groups do
let(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do
expect(epic3.ancestors).to match_array([epic1, epic2])
end
it 'returns an empty array if an epic does not have any parent' do
expect(epic1.ancestors).to be_empty
end
end
describe '#descendants', :nested_groups do
let(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do
expect(epic1.descendants).to match_array([epic2, epic3])
end
it 'returns an empty array if an epic does not have any descendants' do
expect(epic3.descendants).to be_empty
end
end
describe '#upcoming?' do describe '#upcoming?' do
it 'returns true when start_date is in the future' do it 'returns true when start_date is in the future' do
epic = build(:epic, start_date: 1.month.from_now) epic = build(:epic, start_date: 1.month.from_now)
......
...@@ -188,7 +188,7 @@ describe Namespace do ...@@ -188,7 +188,7 @@ describe Namespace do
end end
end end
if Group.supports_nested_groups? if Group.supports_nested_objects?
context 'when license is applied to parent group' do context 'when license is applied to parent group' do
let(:child_group) { create :group, parent: group } let(:child_group) { create :group, parent: group }
......
# frozen_string_literal: true
require 'spec_helper'
describe EpicLinks::CreateService, :postgresql do
describe '#execute' do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
let(:epic_to_add) { create(:epic, group: group) }
let(:valid_reference) { epic_to_add.to_reference(full: true) }
shared_examples 'returns success' do
it 'creates a new relationship and updates epic' do
expect { subject }.to change { epic.children.count }.by(1)
expect(epic.reload.children).to include(epic_to_add)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
shared_examples 'returns not found error' do
it 'returns an error' do
expect(subject).to eq(message: 'No Epic found for given params', status: :error, http_status: 404)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
def add_epic(references)
params = { issuable_references: references }
described_class.new(epic, user, params).execute
end
context 'when epics feature is disabled' do
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when user has permissions to link the issue' do
before do
group.add_developer(user)
end
context 'when the reference list is empty' do
subject { add_epic([]) }
include_examples 'returns not found error'
end
context 'when a correct reference is given' do
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
context 'when an epic from a subgroup is given' do
let(:subgroup) { create(:group, parent: group) }
before do
epic_to_add.update!(group: subgroup)
end
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
context 'when an epic from a another group is given' do
let(:other_group) { create(:group) }
before do
epic_to_add.update!(group: other_group)
end
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
context 'when multiple valid epics are given' do
let(:another_epic) { create(:epic, group: group) }
subject do
add_epic(
[epic_to_add.to_reference(full: true), another_epic.to_reference(full: true)]
)
end
it 'creates new relationships' do
expect { subject }.to change { epic.children.count }.by(2)
expect(epic.reload.children).to match_array([epic_to_add, another_epic])
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
context 'when at least one epic is still not assigned to the parent epic' do
let(:another_epic) { create(:epic, group: group) }
before do
epic_to_add.update(parent: epic)
end
subject do
add_epic(
[epic_to_add.to_reference(full: true), another_epic.to_reference(full: true)]
)
end
it 'creates new relationships' do
expect { subject }.to change { epic.children.count }.from(1).to(2)
expect(epic.reload.children).to match_array([epic_to_add, another_epic])
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
context 'when adding an epic that is already a child of the parent epic' do
before do
epic_to_add.update(parent: epic)
end
subject { add_epic([valid_reference]) }
it 'returns an error' do
expect(subject).to eq(message: 'Epic(s) already assigned', status: :error, http_status: 409)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
context 'when adding an epic would would exceed level 5 in hierarchy' do
context 'when adding to already deep structure' do
before do
epic1 = create(:epic, group: group)
epic2 = create(:epic, group: group, parent: epic1)
epic3 = create(:epic, group: group, parent: epic2)
epic4 = create(:epic, group: group, parent: epic3)
epic.update(parent: epic4)
end
subject { add_epic([valid_reference]) }
it 'returns an error' do
expect(subject).to eq(message: 'Epic hierarchy level too deep', status: :error, http_status: 409)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
context 'when adding an epic already having some epics as children' do
before do
epic1 = create(:epic, group: group)
epic.update(parent: epic1) # epic is on level 2
# epic_to_add has 3 children (level 4 inlcuding epic_to_add)
# that would mean level 6 after relating epic_to_add on epic
epic2 = create(:epic, group: group, parent: epic_to_add)
epic3 = create(:epic, group: group, parent: epic2)
create(:epic, group: group, parent: epic3)
end
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
end
context 'when an epic is already assigned to another epic' do
let(:another_epic) { create(:epic, group: group) }
before do
epic_to_add.update(parent: another_epic)
end
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EpicLinks::ListService, :postgresql do
let(:user) { create :user }
let(:group) { create(:group, :public) }
let(:parent_epic) { create(:epic, group: group) }
let!(:epic1) { create :epic, group: group, parent: parent_epic }
let!(:epic2) { create :epic, group: group, parent: parent_epic }
def epics_to_results(epics)
epics.map do |epic|
{
id: epic.id,
title: epic.title,
state: epic.state,
reference: epic.to_reference(group),
path: "/groups/#{epic.group.full_path}/-/epics/#{epic.iid}",
relation_path: "/groups/#{epic.group.full_path}/-/epics/#{parent_epic.iid}/links/#{epic.id}"
}
end
end
describe '#execute' do
subject { described_class.new(parent_epic, user).execute }
context 'when epics feature is disabled' do
it 'returns an empty array' do
group.add_developer(user)
expect(subject).to be_empty
end
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'group member can see all child epics' do
before do
group.add_developer(user)
end
it 'returns related issues JSON' do
expected_result = epics_to_results([epic1, epic2])
expect(subject).to match_array(expected_result)
end
end
context 'with nested groups' do
let(:subgroup1) { create(:group, :private, parent: group) }
let(:subgroup2) { create(:group, :private, parent: group) }
let!(:epic_subgroup1) { create :epic, group: subgroup1, parent: parent_epic }
let!(:epic_subgroup2) { create :epic, group: subgroup2, parent: parent_epic }
it 'returns all child epics for a group member' do
group.add_developer(user)
expected_result = epics_to_results([epic1, epic2, epic_subgroup1, epic_subgroup2])
expect(subject).to match_array(expected_result)
end
it 'returns only some child epics for a subgroup member' do
subgroup2.add_developer(user)
expected_result = epics_to_results([epic1, epic2, epic_subgroup2])
expect(subject).to match_array(expected_result)
end
end
end
end
end
...@@ -45,7 +45,7 @@ module Gitlab ...@@ -45,7 +45,7 @@ module Gitlab
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
# Returns a relation that includes the ancestors_base set of groups # Returns a relation that includes the ancestors_base set of objects
# and all their ancestors (recursively). # and all their ancestors (recursively).
# #
# Passing an `upto` will stop the recursion once the specified parent_id is # Passing an `upto` will stop the recursion once the specified parent_id is
......
...@@ -6186,6 +6186,9 @@ msgstr "" ...@@ -6186,6 +6186,9 @@ msgstr ""
msgid "Pagination|« First" msgid "Pagination|« First"
msgstr "" msgstr ""
msgid "Parent epic"
msgstr ""
msgid "Part of merge request changes" msgid "Part of merge request changes"
msgstr "" msgstr ""
......
...@@ -197,32 +197,6 @@ describe IssuablesHelper do ...@@ -197,32 +197,6 @@ describe IssuablesHelper do
} }
expect(helper.issuable_initial_data(issue)).to eq(expected_data) expect(helper.issuable_initial_data(issue)).to eq(expected_data)
end end
it 'returns the correct data for an epic' do
epic = create(:epic, author: user, description: 'epic text')
@group = epic.group
expected_data = {
endpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
canUpdate: true,
canDestroy: true,
canAdmin: true,
issuableRef: "&#{epic.iid}",
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION,
issuableTemplates: nil,
groupPath: @group.path,
initialTitleHtml: epic.title,
initialTitleText: epic.title,
initialDescriptionHtml: '<p dir="auto">epic text</p>',
initialDescriptionText: 'epic text',
initialTaskStatus: '0 of 0 tasks completed'
}
expect(helper.issuable_initial_data(epic)).to eq(expected_data)
end
end end
describe '#selected_labels' do describe '#selected_labels' do
......
...@@ -119,7 +119,7 @@ describe Gitlab::ObjectHierarchy, :postgresql do ...@@ -119,7 +119,7 @@ describe Gitlab::ObjectHierarchy, :postgresql do
end end
end end
describe '#all_groups' do describe '#all_objects' do
let(:relation) do let(:relation) do
described_class.new(Group.where(id: child1.id)).all_objects described_class.new(Group.where(id: child1.id)).all_objects
end end
......
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