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
t.integer "state", limit: 2, default: 1, null: false
t.integer "closed_by_id"
t.datetime "closed_at"
t.integer "parent_id"
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 ["closed_by_id"], name: "index_epics_on_closed_by_id", using: :btree
......@@ -1047,6 +1048,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
t.index ["group_id"], name: "index_epics_on_group_id", using: :btree
t.index ["iid"], name: "index_epics_on_iid", 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
end
......@@ -3227,6 +3229,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
add_foreign_key "epic_issues", "epics", on_delete: :cascade
add_foreign_key "epic_issues", "issues", 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", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
......
......@@ -13,6 +13,7 @@ import { status, stateEvent } from '../../constants';
export default {
name: 'EpicShowApp',
epicsPathIdSeparator: '&',
components: {
epicHeader,
epicSidebar,
......@@ -44,6 +45,11 @@ export default {
required: true,
type: Boolean,
},
subepicsSupported: {
type: Boolean,
required: false,
default: true,
},
markdownPreviewPath: {
type: String,
required: true,
......@@ -82,6 +88,10 @@ export default {
type: Object,
required: true,
},
epicLinksEndpoint: {
type: String,
required: true,
},
issueLinksEndpoint: {
type: String,
required: true,
......@@ -146,6 +156,11 @@ export default {
type: Array,
required: true,
},
parent: {
type: Object,
required: false,
default: () => ({}),
},
participants: {
type: Array,
required: true,
......@@ -198,7 +213,9 @@ export default {
return {
// Epics specific configuration
issuableRef: '',
hasRelatedEpicsFeature: this.subepicsSupported && gon.features && gon.features.epicLinks,
projectPath: this.groupPath,
parentEpic: this.parent ? this.parent : {},
projectNamespace: '',
service: new EpicsService({
endpoint: this.endpoint,
......@@ -293,6 +310,7 @@ export default {
:initial-participants="participants"
:initial-subscribed="subscribed"
:initial-todo-exists="todoExists"
:parent="parentEpic"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
......@@ -302,12 +320,24 @@ export default {
:labels-web-url="labelsWebUrl"
: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
:endpoint="issueLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
:allow-auto-complete="false"
title="Issues"
:title="__('Issues')"
css-class="js-related-issues-block"
path-id-separator="#"
/>
</div>
</div>
......
......@@ -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 ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.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 eventHub from '../../event_hub';
import SidebarDatePicker from './sidebar_date_picker.vue';
......@@ -33,6 +34,7 @@ export default {
SidebarDatePicker,
SidebarCollapsedGroupedDatePicker,
SidebarLabelsSelect,
SidebarItemEpic,
SidebarParticipants,
SidebarSubscriptions,
},
......@@ -116,6 +118,10 @@ export default {
type: Boolean,
required: true,
},
parent: {
type: Object,
required: true,
},
namespace: {
type: String,
required: false,
......@@ -549,6 +555,9 @@ export default {
@toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('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-subscriptions
:loading="savingSubscription"
......
......@@ -2,7 +2,6 @@
import $ from 'jquery';
import GfmAutoComplete from '~/gfm_auto_complete';
import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import issueToken from './issue_token.vue';
export default {
......@@ -60,7 +59,6 @@ export default {
mounted() {
const $input = $(this.$refs.input);
if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
......@@ -83,7 +81,10 @@ export default {
methods: {
onInput() {
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() {
this.isInputFocused = true;
......@@ -94,7 +95,7 @@ export default {
// Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) {
const { value } = this.$refs.input;
eventHub.$emit('addIssuableFormBlur', value);
this.$emit('addIssuableFormBlur', value);
}
},
onAutoCompleteToggled(isOpen) {
......@@ -103,12 +104,15 @@ export default {
onInputWrapperClick() {
this.$refs.input.focus();
},
onPendingIssuableRemoveRequest(params) {
this.$emit('pendingIssuableRemoveRequest', params);
},
onFormSubmit() {
const { value } = this.$refs.input;
eventHub.$emit('addIssuableFormSubmit', value);
this.$emit('addIssuableFormSubmit', value);
},
onFormCancel() {
eventHub.$emit('addIssuableFormCancel');
this.$emit('addIssuableFormCancel');
},
},
};
......@@ -141,6 +145,7 @@ export default {
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
/>
</li>
<li class="add-issuable-form-input-list-item">
......
......@@ -89,7 +89,7 @@ export default {
</div>
<div class="item-meta-child d-flex align-items-center">
<issue-milestone
v-if="milestone"
v-if="hasMilestone"
:milestone="milestone"
class="d-flex align-items-center item-milestone"
/>
......
......@@ -4,7 +4,6 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import sortableConfig from 'ee/sortable/sortable_config';
import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import issueItem from './issue_item.vue';
import addIssuableForm from './add_issuable_form.vue';
......@@ -62,8 +61,7 @@ export default {
},
pathIdSeparator: {
type: String,
required: false,
default: '#',
required: true,
},
helpPath: {
type: String,
......@@ -110,9 +108,6 @@ export default {
}
},
methods: {
toggleAddRelatedIssuesForm() {
eventHub.$emit('toggleAddRelatedIssuesForm');
},
getBeforeAfterId(itemEl) {
const prevItemEl = itemEl.previousElementSibling;
const nextItemEl = itemEl.nextElementSibling;
......@@ -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"
aria-label="Add an issue"
data-placement="top"
@click="toggleAddRelatedIssuesForm"
@click="$emit('toggleAddRelatedIssuesForm', $event);"
>
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
......@@ -192,6 +187,11 @@ export default {
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
: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
......@@ -238,6 +238,7 @@ export default {
:can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event);"
/>
</li>
</ul>
......
......@@ -25,7 +25,6 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/
import _ from 'underscore';
import Flash from '~/flash';
import eventHub from '../event_hub';
import RelatedIssuesBlock from './related_issues_block.vue';
import RelatedIssuesStore from '../stores/related_issues_store';
import RelatedIssuesService from '../services/related_issues_service';
......@@ -67,6 +66,16 @@ export default {
required: false,
default: true,
},
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
cssClass: {
type: String,
required: false,
default: '',
},
},
data() {
this.store = new RelatedIssuesStore();
......@@ -86,26 +95,9 @@ export default {
},
},
created() {
eventHub.$on('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$on('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$on('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$on('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$on('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$on('addIssuableFormInput', this.onInput);
eventHub.$on('addIssuableFormBlur', this.onBlur);
this.service = new RelatedIssuesService(this.endpoint);
this.fetchRelatedIssues();
},
beforeDestroy() {
eventHub.$off('relatedIssue-removeRequest', this.onRelatedIssueRemoveRequest);
eventHub.$off('toggleAddRelatedIssuesForm', this.onToggleAddRelatedIssuesForm);
eventHub.$off('pendingIssuable-removeRequest', this.onPendingIssueRemoveRequest);
eventHub.$off('addIssuableFormSubmit', this.onPendingFormSubmit);
eventHub.$off('addIssuableFormCancel', this.onPendingFormCancel);
eventHub.$off('addIssuableFormInput', this.onInput);
eventHub.$off('addIssuableFormBlur', this.onBlur);
},
methods: {
onRelatedIssueRemoveRequest(idToRemove) {
const issueToRemove = _.find(this.state.relatedIssues, issue => issue.id === idToRemove);
......@@ -198,7 +190,7 @@ export default {
});
}
},
onInput(newValue, caretPos) {
onInput({ newValue, caretPos }) {
const rawReferences = newValue.split(/\s/);
let touchedReference;
......@@ -235,6 +227,7 @@ export default {
<template>
<related-issues-block
:class="cssClass"
:help-path="helpPath"
:is-fetching="isFetching"
:is-submitting="isSubmitting"
......@@ -246,7 +239,14 @@ export default {
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
:title="title"
path-id-separator="#"
:path-id-separator="pathIdSeparator"
@saveReorder="saveIssueOrder"
@toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm"
@addIssuableFormInput="onInput"
@addIssuableFormBlur="onBlur"
@addIssuableFormSubmit="onPendingFormSubmit"
@addIssuableFormCancel="onPendingFormCancel"
@pendingIssuableRemoveRequest="onPendingIssueRemoveRequest"
@relatedIssueRemoveRequest="onRelatedIssueRemoveRequest"
/>
</template>
import Vue from 'vue';
export default new Vue();
import _ from 'underscore';
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
const mixins = {
data() {
......@@ -104,6 +104,9 @@ const mixins = {
hasTitle() {
return this.title.length > 0;
},
hasMilestone() {
return !_.isEmpty(this.milestone);
},
iconName() {
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
......@@ -139,10 +142,10 @@ const mixins = {
onRemoveRequest() {
let namespacePrefix = '';
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;
},
......
......@@ -13,29 +13,44 @@ export default {
components: {
GlLoadingIcon,
},
props: {
blockTitle: {
type: String,
required: false,
default: __('Epic'),
},
initialEpic: {
type: Object,
required: false,
default: () => null,
},
},
data() {
return {
store: new Store(),
store: !this.initialEpic ? new Store() : {},
};
},
computed: {
isLoading() {
return this.store.isFetching.epic;
return this.initialEpic ? false : this.store.isFetching.epic;
},
epic() {
return this.initialEpic || this.store.epic;
},
epicIcon() {
return spriteIcon('epic');
},
epicUrl() {
return this.store.epic.url;
return this.epic.url;
},
epicTitle() {
return this.store.epic.title;
return this.epic.title;
},
hasEpic() {
return this.epicUrl && this.epicTitle;
},
collapsedTitle() {
return this.hasEpic ? this.epicTitle : 'None';
return this.hasEpic ? this.epicTitle : __('None');
},
tooltipTitle() {
if (!this.hasEpic) {
......@@ -43,13 +58,13 @@ export default {
}
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 += this.store.epic.human_readable_end_date
? `${this.store.epic.human_readable_end_date} `
tooltipTitle += this.epic.human_readable_end_date
? `${this.epic.human_readable_end_date} `
: '';
tooltipTitle += this.store.epic.human_readable_timestamp
? `(${this.store.epic.human_readable_timestamp})`
tooltipTitle += this.epic.human_readable_timestamp
? `(${this.epic.human_readable_timestamp})`
: '';
}
......@@ -71,15 +86,15 @@ export default {
data-boundary="viewport"
>
<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 class="title hide-collapsed">
Epic
{{ blockTitle }}
<gl-loading-icon v-if="isLoading" :inline="true" />
</div>
<div v-if="!isLoading" class="value hide-collapsed">
<a v-if="hasEpic" :href="epicUrl" class="bold"> {{ epicTitle }} </a>
<span v-else class="no-value"> None </span>
<a v-if="hasEpic" :href="epicUrl" class="bold">{{ epicTitle }}</a>
<span v-else class="no-value">{{ __('None') }}</span>
</div>
</div>
</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
include RendersNotes
include EpicsActions
before_action do
push_frontend_feature_flag(:epic_links)
end
before_action :check_epics_available!
before_action :epic, except: [:index, :create]
before_action :set_issuables_index, only: :index
......
......@@ -30,6 +30,7 @@ class EpicsFinder < IssuableFinder
items = by_timeframe(items)
items = by_state(items)
items = by_label(items)
items = by_parent(items)
sort(items)
end
......@@ -89,4 +90,16 @@ class EpicsFinder < IssuableFinder
items
end
# 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
......@@ -108,10 +108,10 @@ module Geo
def group_uploads
namespace_ids =
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?
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
Namespace.none
end
......
......@@ -20,6 +20,8 @@ module EE
if parent.is_a?(Group)
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
data[:epicLinksEndpoint] = group_epic_links_path(parent, issuable)
data[:subepicsSupported] = ::Epic.supports_nested_objects?
end
data
......
......@@ -9,6 +9,7 @@ module EpicsHelper
epic_id: epic.id,
created: epic.created_at,
author: epic_author(epic, opts),
parent: epic_parent(epic.parent),
todo_exists: todo.present?,
todo_path: group_todos_path(group),
start_date: epic.start_date,
......@@ -67,6 +68,16 @@ module EpicsHelper
}
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)
opts[:data] ||= {}
opts[:data][:endpoint_query_params] = {
......
......@@ -12,6 +12,7 @@ module EE
include Referable
include Awardable
include LabelEventable
include Descendant
enum state: { opened: 1, closed: 2 }
......@@ -33,6 +34,8 @@ module EE
belongs_to :group
belongs_to :start_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) }
......@@ -215,6 +218,20 @@ module EE
from && from != group
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
def update_project_counter_caches
end
......
......@@ -186,7 +186,7 @@ module EE
project_creation_levels << nil
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),
project_creation_level: project_creation_levels)
end
......
......@@ -187,7 +187,7 @@ class GeoNode < ActiveRecord::Base
return Project.all unless selective_sync?
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))
elsif selective_sync_by_shards?
Project.where(repository_storage: selective_sync_shards)
......
......@@ -52,7 +52,7 @@ module EE
namespaces = ::Namespace.reorder(nil).where('namespaces.id = projects.namespace_id')
if ::Feature.enabled?(:shared_runner_minutes_on_root_namespace)
namespaces = ::Gitlab::GroupHierarchy.new(namespaces).roots
namespaces = ::Gitlab::ObjectHierarchy.new(namespaces).roots
end
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
end
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
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
# Returns an ActiveRecord::Relation that includes the given groups, and all
# their (recursive) ancestors.
def groups_and_ancestors_for(groups)
Gitlab::GroupHierarchy
Gitlab::ObjectHierarchy
.new(groups)
.base_and_ancestors
.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
end
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_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button')
......@@ -40,11 +40,11 @@ describe 'Epic Issues', :js do
end
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
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
......@@ -53,13 +53,13 @@ describe 'Epic Issues', :js do
let(:issue_invalid) { create(:issue) }
def add_issues(references)
find('.related-issues-block h3.card-title button').click
find('.js-add-issuable-form-input').set(references)
find('.js-related-issues-block h3.card-title button').click
find('.js-related-issues-block .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-add-issuable-form-input').send_keys(:tab)
find('.js-add-issuable-form-add-button').click
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
wait_for_requests
end
......@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end
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_content(public_issue.title)
expect(page).to have_content(private_issue.title)
......@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
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)
end
end
......@@ -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_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_content(issue_to_add.title)
end
end
it 'user can reorder issues in epic' do
expect(first('.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(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: '.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(page.all('.js-related-issues-token-list-item').last).to have_content(public_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-block .js-related-issues-token-list-item').last).to have_content(public_issue.title)
end
end
end
......@@ -28,6 +28,10 @@ describe 'Epic in issue sidebar', :js do
context 'when epics available' do
before do
stub_licensed_features(epics: true)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
it_behaves_like 'epic in issue sidebar'
......
......@@ -169,6 +169,21 @@ describe EpicsFinder do
expect(epics(params)).to contain_exactly(epic3)
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
......
# 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
describe '#epic_show_app_data' do
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(: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
create(
:epic,
group: group,
author: user,
start_date_sourcing_milestone: milestone1,
start_date: Date.new(2000, 1, 1),
due_date_sourcing_milestone: milestone2,
due_date: Date.new(2000, 1, 2)
due_date: Date.new(2000, 1, 2),
parent: parent_epic
)
end
......@@ -30,7 +34,7 @@ describe EpicsHelper do
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(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_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
......@@ -43,6 +47,11 @@ describe EpicsHelper do
'username' => "@#{user.username}",
'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_sourcing_milestone_title']).to eq(milestone1.title)
expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s)
......@@ -76,7 +85,7 @@ describe EpicsHelper do
meta_data = JSON.parse(data[:meta])
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_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
......
......@@ -40,6 +40,7 @@ export const contentProps = {
markdownPreviewPath: '',
markdownDocsPath: '',
issueLinksEndpoint: '/',
epicLinksEndpoint: '/',
groupPath: '',
namespace: 'gitlab-org',
labelsPath: '',
......@@ -71,6 +72,15 @@ export const contentProps = {
subscribed: true,
todoExists: false,
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 = {
......
......@@ -19,6 +19,7 @@ describe('epicSidebar', () => {
labelsWebUrl,
epicsWebUrl,
labels,
parent,
participants,
subscribed,
toggleSubscriptionPath,
......@@ -55,6 +56,7 @@ describe('epicSidebar', () => {
startDateSourcingMilestoneDates,
dueDateSourcingMilestoneTitle,
dueDateSourcingMilestoneDates,
parent,
toggleSubscriptionPath,
labelsPath,
labelsWebUrl,
......@@ -115,6 +117,47 @@ describe('epicSidebar', () => {
).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', () => {
const getComponent = (
customPropsData = {
......
import $ from 'jquery';
import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import addIssuableForm from 'ee/related_issues/components/add_issuable_form.vue';
const issuable1 = {
......@@ -167,21 +166,7 @@ describe('AddIssuableForm', () => {
});
describe('methods', () => {
let addIssuableFormInputSpy;
let addIssuableFormBlurSpy;
let addIssuableFormSubmitSpy;
let addIssuableFormCancelSpy;
beforeEach(() => {
addIssuableFormInputSpy = jasmine.createSpy('spy');
addIssuableFormBlurSpy = jasmine.createSpy('spy');
addIssuableFormSubmitSpy = jasmine.createSpy('spy');
addIssuableFormCancelSpy = jasmine.createSpy('spy');
eventHub.$on('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$on('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$on('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$on('addIssuableFormCancel', addIssuableFormCancelSpy);
const el = document.createElement('div');
// We need to append to body to get focus tests working
document.body.appendChild(el);
......@@ -198,13 +183,6 @@ describe('AddIssuableForm', () => {
}).$mount(el);
});
afterEach(() => {
eventHub.$off('addIssuableFormInput', addIssuableFormInputSpy);
eventHub.$off('addIssuableFormBlur', addIssuableFormBlurSpy);
eventHub.$off('addIssuableFormSubmit', addIssuableFormSubmitSpy);
eventHub.$off('addIssuableFormCancel', addIssuableFormCancelSpy);
});
it('when clicking somewhere on the input wrapper should focus the input', done => {
vm.onInputWrapperClick();
......@@ -219,18 +197,19 @@ describe('AddIssuableForm', () => {
});
it('when filling in the input', () => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled();
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onInput();
expect(addIssuableFormInputSpy).toHaveBeenCalledWith(newInputValue, newInputValue.length);
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue,
caretPos: newInputValue.length,
});
});
it('when blurring the input', done => {
expect(addIssuableFormInputSpy).not.toHaveBeenCalled();
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onBlur();
......@@ -238,7 +217,7 @@ describe('AddIssuableForm', () => {
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false);
expect(addIssuableFormBlurSpy).toHaveBeenCalledWith(newInputValue);
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormBlur', newInputValue);
done();
});
......@@ -266,29 +245,25 @@ describe('AddIssuableForm', () => {
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.input.value).toEqual('');
expect(addIssuableFormInputSpy.calls.count()).toEqual(1);
done();
});
});
});
it('when submitting pending issues', () => {
expect(addIssuableFormSubmitSpy).not.toHaveBeenCalled();
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onFormSubmit();
expect(addIssuableFormSubmitSpy).toHaveBeenCalledWith(newInputValue);
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', newInputValue);
});
it('when canceling form to collapse', () => {
expect(addIssuableFormCancelSpy).not.toHaveBeenCalled();
spyOn(vm, '$emit');
vm.onFormCancel();
expect(addIssuableFormCancelSpy).toHaveBeenCalled();
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormCancel');
});
});
});
import Vue from '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 { defaultMilestone, defaultAssignees } from '../mock_data';
......@@ -18,6 +17,7 @@ describe('issueItem', () => {
createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone,
assignees: defaultAssignees,
eventNamespace: 'relatedIssue',
};
beforeEach(() => {
......@@ -172,11 +172,10 @@ describe('issueItem', () => {
});
it('triggers onRemoveRequest when clicked', () => {
const spy = jasmine.createSpy('spy');
eventHub.$on('removeRequest', spy);
spyOn(vm, '$emit');
removeBtn.click();
expect(spy).toHaveBeenCalled();
expect(vm.$emit).toHaveBeenCalledWith('relatedIssueRemoveRequest', props.idKey);
});
});
});
import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import issueToken from 'ee/related_issues/components/issue_token.vue';
describe('IssueToken', () => {
......@@ -7,6 +6,7 @@ describe('IssueToken', () => {
const displayReference = 'foo/bar#123';
const title = 'some title';
const pathIdSeparator = '#';
const eventNamespace = 'pendingIssuable';
let IssueToken;
let vm;
......@@ -25,6 +25,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
},
......@@ -46,6 +47,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
title,
......@@ -65,6 +67,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
title,
......@@ -84,6 +87,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
state: 'opened',
......@@ -101,6 +105,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
state: 'reopened',
......@@ -118,6 +123,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
state: 'closed',
......@@ -137,6 +143,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
title,
......@@ -160,6 +167,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
},
......@@ -176,6 +184,7 @@ describe('IssueToken', () => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
canRemove: true,
......@@ -190,29 +199,22 @@ describe('IssueToken', () => {
});
describe('methods', () => {
let removeRequestSpy;
beforeEach(() => {
vm = new IssueToken({
propsData: {
idKey,
eventNamespace,
displayReference,
pathIdSeparator,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
eventHub.$on('removeRequest', removeRequestSpy);
});
afterEach(() => {
eventHub.$off('removeRequest', removeRequestSpy);
});
it('when getting checked', () => {
expect(removeRequestSpy).not.toHaveBeenCalled();
spyOn(vm, '$emit');
vm.onRemoveRequest();
expect(removeRequestSpy).toHaveBeenCalled();
expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey);
});
});
});
import Vue from 'vue';
import eventHub from 'ee/related_issues/event_hub';
import relatedIssuesBlock from 'ee/related_issues/components/related_issues_block.vue';
import { issuable1, issuable2, issuable3, issuable4, issuable5 } from '../mock_data';
......@@ -20,7 +19,11 @@ describe('RelatedIssuesBlock', () => {
describe('with defaults', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock().$mount();
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
},
}).$mount();
});
it('unable to add new related issues', () => {
......@@ -40,6 +43,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
isFetching: true,
},
}).$mount();
......@@ -58,6 +62,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
canAdmin: true,
},
}).$mount();
......@@ -72,6 +77,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
isFormVisible: true,
},
}).$mount();
......@@ -86,6 +92,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
relatedIssues: [issuable1, issuable2],
},
}).$mount();
......@@ -97,20 +104,13 @@ describe('RelatedIssuesBlock', () => {
});
describe('methods', () => {
let toggleAddRelatedIssuesFormSpy;
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
pathIdSeparator: '#',
relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
},
}).$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', () => {
......@@ -140,12 +140,5 @@ describe('RelatedIssuesBlock', () => {
expect(beforeAfterIds.beforeId).toBe(3);
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', () => {
it('fill in issue number reference and adds to pending related issues', () => {
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[0]).toEqual('#123');
......@@ -308,7 +311,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with full reference', () => {
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[0]).toEqual('asdf/qwer#444');
......@@ -317,7 +320,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with issue link', () => {
const link = 'http://localhost:3000/foo/bar/issues/111';
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[0]).toEqual(link);
......@@ -325,7 +328,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with multiple references', () => {
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[0]).toEqual('asdf/qwer#444');
......@@ -334,7 +337,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in with some invalid things', () => {
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[0]).toEqual('something');
......@@ -343,7 +346,7 @@ describe('RelatedIssuesRoot', () => {
it('fill in invalid and some legit references', () => {
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[0]).toEqual('something');
......@@ -353,7 +356,7 @@ describe('RelatedIssuesRoot', () => {
it('keep reference piece in input while we are touching it', () => {
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[0]).toEqual('a');
......
......@@ -16,7 +16,9 @@ describe('sidebarItemEpic', () => {
});
const SidebarItemEpic = Vue.extend(sidebarItemEpic);
vm = mountComponent(SidebarItemEpic, {});
vm = mountComponent(SidebarItemEpic, {
initialEpic: null,
});
});
afterEach(() => {
......
......@@ -7,7 +7,9 @@ describe Epic do
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(:group) }
it { is_expected.to belong_to(:parent) }
it { is_expected.to have_many(:epic_issues) }
it { is_expected.to have_many(:children) }
end
describe 'validations' do
......@@ -77,6 +79,36 @@ describe Epic do
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
it 'returns true when start_date is in the future' do
epic = build(:epic, start_date: 1.month.from_now)
......
......@@ -188,7 +188,7 @@ describe Namespace do
end
end
if Group.supports_nested_groups?
if Group.supports_nested_objects?
context 'when license is applied to parent group' do
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
end
# 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).
#
# Passing an `upto` will stop the recursion once the specified parent_id is
......
......@@ -6186,6 +6186,9 @@ msgstr ""
msgid "Pagination|« First"
msgstr ""
msgid "Parent epic"
msgstr ""
msgid "Part of merge request changes"
msgstr ""
......
......@@ -197,32 +197,6 @@ describe IssuablesHelper do
}
expect(helper.issuable_initial_data(issue)).to eq(expected_data)
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
describe '#selected_labels' do
......
......@@ -119,7 +119,7 @@ describe Gitlab::ObjectHierarchy, :postgresql do
end
end
describe '#all_groups' do
describe '#all_objects' do
let(:relation) do
described_class.new(Group.where(id: child1.id)).all_objects
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