Commit 5e0cb86c authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '37081-epics-premium-final' into 'master'

Move epics to premium

Closes #37081

See merge request gitlab-org/gitlab!25184
parents 3a9007d2 a65ea302
# Epics API **(ULTIMATE)**
# Epics API **(PREMIUM)**
Every API call to epic must be authenticated.
......
......@@ -30,6 +30,7 @@ export default {
computed: {
...mapState([
'canUpdate',
'allowSubEpics',
'sidebarCollapsed',
'participants',
'startDateSourcingMilestoneTitle',
......@@ -186,7 +187,7 @@ export default {
@toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/>
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" />
<div class="block ancestors">
<div v-if="allowSubEpics" class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" />
</div>
<div class="block participants">
......
......@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import createStore from './store';
import EpicApp from './components/epic_app.vue';
......@@ -54,7 +54,10 @@ export default (epicCreate = false) => {
store,
components: { EpicApp },
created() {
this.setEpicMeta(epicMeta);
this.setEpicMeta({
...epicMeta,
allowSubEpics: parseBoolean(el.dataset.allowSubEpics),
});
this.setEpicData(epicData);
},
methods: {
......
import $ from 'jquery';
import { parseBoolean } from '~/lib/utils/common_utils';
import initRelatedItemsTree from 'ee/related_items_tree/related_items_tree_bundle';
import initRoadmap from 'ee/roadmap/roadmap_bundle';
export default class EpicTabs {
constructor() {
......@@ -8,12 +8,31 @@ export default class EpicTabs {
this.wrapper = document.querySelector('.content-wrapper .container-fluid:not(.breadcrumbs)');
this.epicTabs = this.wrapper.querySelector('.js-epic-tabs-container');
this.discussionFilterContainer = this.epicTabs.querySelector('.js-discussion-filter-container');
const allowSubEpics = parseBoolean(this.epicTabs.dataset.allowSubEpics);
initRelatedItemsTree();
this.roadmapTabLoaded = false;
// We need to execute Roadmap tab related
// logic only when sub-epics feature is available.
if (allowSubEpics) {
this.roadmapTabLoaded = false;
this.bindEvents();
this.loadRoadmapBundle();
this.bindEvents();
}
}
/**
* This method loads Roadmap app bundle asynchronously.
*
* @param {boolean} allowSubEpics
*/
loadRoadmapBundle() {
import('ee/roadmap/roadmap_bundle')
.then(roadmapBundle => {
this.initRoadmap = roadmapBundle.default;
})
.catch(() => {});
}
bindEvents() {
......@@ -26,7 +45,7 @@ export default class EpicTabs {
onRoadmapShow() {
this.wrapper.classList.remove('container-limited');
if (!this.roadmapTabLoaded) {
initRoadmap();
this.initRoadmap();
this.roadmapTabLoaded = true;
}
}
......
......@@ -19,6 +19,7 @@ export default () => ({
canUpdate: false,
canDestroy: false,
canAdmin: false,
allowSubEpics: false,
// Epic Information
epicId: 0,
......
......@@ -17,7 +17,7 @@ export default {
EpicActionsSplitButton,
},
computed: {
...mapState(['parentItem', 'descendantCounts']),
...mapState(['parentItem', 'descendantCounts', 'allowSubEpics']),
totalEpicsCount() {
return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics;
},
......@@ -51,7 +51,7 @@ export default {
<div class="card-header d-flex px-2">
<div class="d-inline-flex flex-grow-1 lh-100 align-middle">
<gl-tooltip :target="() => $refs.countBadge">
<p class="font-weight-bold m-0">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
>{{
......@@ -75,11 +75,11 @@ export default {
</p>
</gl-tooltip>
<div ref="countBadge" class="issue-count-badge">
<span class="d-inline-flex align-items-center">
<span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<icon :size="16" name="epic" class="text-secondary mr-1" />
{{ totalEpicsCount }}
</span>
<span class="ml-2 d-inline-flex align-items-center">
<span class="d-inline-flex align-items-center" :class="{ 'ml-2': allowSubEpics }">
<icon :size="16" name="issues" class="text-secondary mr-1" />
{{ totalIssuesCount }}
</span>
......@@ -88,6 +88,7 @@ export default {
<div class="d-inline-flex js-button-container">
<template v-if="parentItem.userPermissions.adminEpic">
<epic-actions-split-button
v-if="allowSubEpics"
class="qa-add-epics-button"
@showAddEpicForm="showAddEpicForm"
@showCreateEpicForm="showCreateEpicForm"
......
......@@ -16,7 +16,15 @@ export default () => {
return false;
}
const { id, iid, fullPath, autoCompleteEpics, autoCompleteIssues, userSignedIn } = el.dataset;
const {
id,
iid,
fullPath,
autoCompleteEpics,
autoCompleteIssues,
userSignedIn,
allowSubEpics,
} = el.dataset;
const initialData = JSON.parse(el.dataset.initial);
Vue.component('tree-root', TreeRoot);
......@@ -46,6 +54,7 @@ export default () => {
autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn),
allowSubEpics: parseBoolean(allowSubEpics),
});
},
methods: {
......
......@@ -12,6 +12,7 @@ export default {
autoCompleteIssues,
projectsEndpoint,
userSignedIn,
allowSubEpics,
},
) {
state.epicsEndpoint = epicsEndpoint;
......@@ -20,6 +21,7 @@ export default {
state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn;
state.allowSubEpics = allowSubEpics;
},
[types.SET_INITIAL_PARENT_ITEM](state, data) {
......
......@@ -36,6 +36,7 @@ export default () => ({
showCreateEpicForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
allowSubEpics: false,
removeItemModalProps: {
parentItem: {},
item: {},
......
......@@ -3,8 +3,8 @@
class Groups::EpicLinksController < Groups::ApplicationController
include EpicRelations
before_action :check_epics_available!, only: :index
before_action :check_subepics_available!, only: [:create, :destroy, :update]
before_action :check_epics_available!, only: [:index, :destroy]
before_action :check_subepics_available!, only: [:create, :update]
def update
result = EpicLinks::UpdateService.new(child_epic, current_user, params[:epic]).execute
......
......@@ -66,6 +66,7 @@ class License < ApplicationRecord
design_management
disable_name_update_for_users
email_additional_text
epics
extended_audit_events
external_authorization_service_api_management
feature_flags
......@@ -111,7 +112,6 @@ class License < ApplicationRecord
credentials_inventory
dast
dependency_scanning
epics
group_ip_restriction
group_level_compliance_dashboard
incident_management
......
......@@ -30,6 +30,6 @@ class LinkedEpicEntity < Grape::Entity
end
expose :can_admin do |epic|
can?(request.current_user, :admin_epic, epic)
can?(request.current_user, :admin_epic_link, epic)
end
end
......@@ -26,8 +26,8 @@ module EpicLinks
def permission_to_remove_relation?
child_epic.present? &&
parent_epic.present? &&
can?(current_user, :admin_epic_link, parent_epic) &&
can?(current_user, :admin_epic_link, child_epic)
can?(current_user, :admin_epic, parent_epic) &&
can?(current_user, :admin_epic, child_epic)
end
def not_found_message
......
......@@ -3,6 +3,9 @@
- @content_class = "limit-container-width" unless fluid_layout
- epic_reference = @epic.to_reference
- sub_epics_feature_available = @group.feature_available?(:subepics)
- allow_sub_epics = sub_epics_feature_available ? 'true' : 'false'
- add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference
......@@ -11,17 +14,22 @@
- page_card_attributes @epic.card_attributes
#epic-app-root{ data: epic_show_app_data(@epic) }
#epic-app-root{ data: epic_show_app_data(@epic),
'data-allow-sub-epics' => allow_sub_epics }
.epic-tabs-holder
.epic-tabs-container.js-epic-tabs-container
.epic-tabs-container.js-epic-tabs-container{ data: { allow_sub_epics: allow_sub_epics } }
%ul.epic-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.tree-tab
%a#tree-tab.active{ href: '#tree', data: { toggle: 'tab' } }
= _('Epics and Issues')
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
- if sub_epics_feature_available
= _('Epics and Issues')
- else
= _('Issues')
- if sub_epics_feature_available
%li.roadmap-tab
%a#roadmap-tab{ href: '#roadmap', data: { toggle: 'tab' } }
= _('Roadmap')
.tab-content.epic-tabs-content.js-epic-tabs-content
#tree.tab-pane.show.active
......@@ -33,22 +41,24 @@
auto_complete_epics: 'true',
auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false',
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json } }
#roadmap.tab-pane
.row
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: 'true' } }
- if sub_epics_feature_available
#roadmap.tab-pane
.row
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
inner_height: '600',
child_epics: 'true' } }
%hr.epic-discussion-separator.mt-1.mb-0
.d-flex.justify-content-between.content-block.content-block-small.emoji-list-container.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
......
---
title: Add single-level Epics to EE Premium
merge_request: 25184
author:
type: added
......@@ -176,6 +176,8 @@ describe Groups::EpicLinksController do
epic1.update(parent: parent_epic)
end
let(:features_when_forbidden) { { epics: false } }
subject { delete :destroy, params: { group_id: group, epic_id: parent_epic.to_param, id: epic1.id } }
it_behaves_like 'unlicensed subepics action'
......
......@@ -98,108 +98,135 @@ describe 'Epic Issues', :js do
visit_epic
end
it 'user can display create new epic form by clicking the dropdown item' do
expect(page).not_to have_selector('input[placeholder="New epic title"]')
context 'handling epics' do
it 'user can display create new epic form by clicking the dropdown item' do
expect(page).not_to have_selector('input[placeholder="New epic title"]')
find('.related-items-tree-container .js-add-epics-button .dropdown-toggle').click
find('.related-items-tree-container .js-add-epics-button .dropdown-item', text: 'Create new epic').click
find('.related-items-tree-container .js-add-epics-button .dropdown-toggle').click
find('.related-items-tree-container .js-add-epics-button .dropdown-item', text: 'Create new epic').click
expect(page).to have_selector('input[placeholder="New epic title"]')
expect(page).to have_selector('input[placeholder="New epic title"]')
end
end
it 'user can see all issues of the group and delete the associations' do
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 2)
expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
context 'handling epic issues' do
it 'user can see all issues of the group and delete the associations' do
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 2)
expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
first('li.js-item-type-issue button.js-issue-item-remove-button').click
first('li.js-item-type-issue button.js-issue-item-remove-button').click
end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 1)
end
end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
it 'user cannot add new issues to the epic from another group' do
add_issues("#{issue_invalid.to_reference(full: true)}")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 1)
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end
end
it 'user can see all epics of the group and delete the associations' do
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 2)
expect(page).to have_content(nested_epics[0].title)
expect(page).to have_content(nested_epics[1].title)
it 'user can add new issues to the epic' do
references = "#{issue_to_add.to_reference(full: true)}"
add_issues(references)
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Issue cannot be found.")
first('li.js-item-type-epic button.js-issue-item-remove-button').click
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 3)
end
end
first('#item-remove-confirmation .modal-footer .btn-danger').click
wait_for_requests
it 'user cannot add new issue that does not exist' do
add_issues("&123")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 1)
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end
end
it 'user cannot add new issues to the epic from another group' do
add_issues("#{issue_invalid.to_reference(full: true)}")
context 'handling epic links' do
context 'when subepics feature is enabled' do
it 'user can see all epics of the group and delete the associations' do
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 2)
expect(page).to have_content(nested_epics[0].title)
expect(page).to have_content(nested_epics[1].title)
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end
first('li.js-item-type-epic button.js-issue-item-remove-button').click
end
first('#item-remove-confirmation .modal-footer .btn-danger').click
it 'user can add new issues to the epic' do
references = "#{issue_to_add.to_reference(full: true)}"
wait_for_requests
add_issues(references)
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 1)
end
end
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Issue cannot be found.")
it 'user cannot add new epic that does not exist' do
add_epics("&123")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-issue', count: 3)
end
end
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Epic cannot be found.")
end
it 'user cannot add new issue that does not exist' do
add_issues("&123")
it 'user can add new epics to the epic' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Issue cannot be found.")
end
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Epic cannot be found.")
it 'user cannot add new epic that does not exist' do
add_epics("&123")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 3)
end
end
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("Epic cannot be found.")
end
context 'when epics are nested too deep' do
let(:epic1) { create(:epic, group: group, parent_id: epic.id) }
let(:epic2) { create(:epic, group: group, parent_id: epic1.id) }
let(:epic3) { create(:epic, group: group, parent_id: epic2.id) }
let(:epic4) { create(:epic, group: group, parent_id: epic3.id) }
context 'when epics are nested too deep' do
let(:epic1) { create(:epic, group: group, parent_id: epic.id) }
let(:epic2) { create(:epic, group: group, parent_id: epic1.id) }
let(:epic3) { create(:epic, group: group, parent_id: epic2.id) }
let(:epic4) { create(:epic, group: group, parent_id: epic3.id) }
before do
visit group_epic_path(group, epic4)
before do
stub_licensed_features(epics: true, subepics: true)
wait_for_requests
sign_in(user)
visit group_epic_path(group, epic4)
find('.js-epic-tabs-container #tree-tab').click
wait_for_requests
wait_for_requests
end
find('.js-epic-tabs-container #tree-tab').click
it 'user cannot add new epic when hierarchy level limit has been reached' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
wait_for_requests
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("This epic can't be added because the parent is already at the maximum depth from its most distant ancestor")
end
end
end
it 'user cannot add new epic when hierarchy level limit has been reached' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
context 'when subepics feature is disabled' do
it 'user can not add new epics to the epic' do
stub_licensed_features(epics: true, subepics: false)
expect(page).to have_selector('.gl-field-error')
expect(find('.gl-field-error')).to have_text("This epic can't be added because the parent is already at the maximum depth from its most distant ancestor")
visit_epic
expect(page).not_to have_selector('.related-items-tree-container .js-add-epics-button')
end
end
end
......@@ -222,17 +249,5 @@ describe 'Epic Issues', :js do
end
end
end
it 'user can add new epics to the epic' do
references = "#{epic_to_add.to_reference(full: true)}"
add_epics(references)
expect(page).not_to have_selector('.gl-field-error')
expect(page).not_to have_content("Epic cannot be found.")
within('.related-items-tree-container ul.related-items-list') do
expect(page).to have_selector('li.js-item-type-epic', count: 3)
end
end
end
end
......@@ -5,7 +5,9 @@ require 'spec_helper'
describe 'Epic show', :js do
let(:user) { create(:user, name: 'Rick Sanchez', username: 'rick.sanchez') }
let(:group) { create(:group, :public) }
let(:public_project) { create(:project, :public, group: group) }
let(:label) { create(:group_label, group: group, title: 'bug') }
let(:public_issue) { create(:issue, project: public_project) }
let(:note_text) { 'Contemnit enim disserendi elegantiam.' }
let(:epic_title) { 'Sample epic' }
......@@ -22,83 +24,116 @@ describe 'Epic show', :js do
let!(:not_child) { create(:epic, group: group, title: 'not child epic', description: markdown, author: user, start_date: 50.days.ago, end_date: 10.days.ago) }
let!(:child_epic_a) { create(:epic, group: group, title: 'Child epic A', description: markdown, parent: epic, start_date: 50.days.ago, end_date: 10.days.ago) }
let!(:child_epic_b) { create(:epic, group: group, title: 'Child epic B', description: markdown, parent: epic, start_date: 100.days.ago, end_date: 20.days.ago) }
let!(:child_issue_a) { create(:epic_issue, epic: epic, issue: public_issue, relative_position: 1) }
before do
group.add_developer(user)
stub_licensed_features(epics: true)
stub_licensed_features(epics: true, subepics: true)
sign_in(user)
visit group_epic_path(group, epic)
end
describe 'Epic metadata' do
it 'shows epic status, date and author in header' do
page.within('.epic-page-container .detail-page-header-body') do
expect(find('.issuable-status-box > span')).to have_content('Open')
expect(find('.issuable-meta')).to have_content('Opened just now by')
expect(find('.issuable-meta .js-user-avatar-link-username')).to have_content('Rick Sanchez')
describe 'when sub-epics feature is available' do
describe 'Epic metadata' do
it 'shows epic tabs `Epics and Issues` and `Roadmap`' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
end
end
end
it 'shows epic title and description' do
page.within('.epic-page-container .detail-page-description') do
expect(find('.title-container .title')).to have_content(epic_title)
expect(find('.description .md')).to have_content(markdown.squish)
end
end
describe 'Epics and Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
it 'shows epic tabs' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Epics and Issues')
expect(find('.epic-tabs #roadmap-tab')).to have_content('Roadmap')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end
end
end
end
it 'shows epic thread filter dropdown' do
page.within('.js-noteable-awards') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
describe 'Roadmap tab' do
before do
find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
end
end
end
describe 'Epics and Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
it 'shows Roadmap timeline with child epics' do
page.within('.js-epic-tabs-content #roadmap') do
expect(page).to have_selector('.roadmap-container .roadmap-shell')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2')
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
end
end
end
it 'does not show thread filter dropdown' do
expect(find('.js-noteable-awards')).to have_selector('.js-discussion-filter-container', visible: false)
end
it 'has no limit on container width' do
expect(find('.content-wrapper .container-fluid:not(.breadcrumbs)')[:class]).not_to include('container-limited')
end
end
end
describe 'Roadmap tab' do
describe 'when sub-epics feature not is available' do
before do
find('.js-epic-tabs-container #roadmap-tab').click
wait_for_requests
stub_licensed_features(epics: true, subepics: false)
visit group_epic_path(group, epic)
end
it 'shows Roadmap timeline with child epics' do
page.within('.js-epic-tabs-content #roadmap') do
expect(page).to have_selector('.roadmap-container .roadmap-shell')
describe 'Epic metadata' do
it 'shows epic tab `Issues`' do
page.within('.js-epic-tabs-container') do
expect(find('.epic-tabs #tree-tab')).to have_content('Issues')
end
end
end
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
describe 'Issues tab' do
it 'shows Related items tree with child epics' do
page.within('.js-epic-tabs-content #tree') do
expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('1')
end
end
end
end
end
it 'does not show thread filter dropdown' do
expect(find('.js-noteable-awards')).to have_selector('.js-discussion-filter-container', visible: false)
describe 'Epic metadata' do
it 'shows epic status, date and author in header' do
page.within('.epic-page-container .detail-page-header-body') do
expect(find('.issuable-status-box > span')).to have_content('Open')
expect(find('.issuable-meta')).to have_content('Opened just now by')
expect(find('.issuable-meta .js-user-avatar-link-username')).to have_content('Rick Sanchez')
end
end
it 'has no limit on container width' do
expect(find('.content-wrapper .container-fluid:not(.breadcrumbs)')[:class]).not_to include('container-limited')
it 'shows epic title and description' do
page.within('.epic-page-container .detail-page-description') do
expect(find('.title-container .title')).to have_content(epic_title)
expect(find('.description .md')).to have_content(markdown.squish)
end
end
it 'shows epic thread filter dropdown' do
page.within('.js-noteable-awards') do
expect(find('.js-discussion-filter-container #discussion-filter-dropdown')).to have_content('Show all activity')
end
end
end
end
......@@ -203,31 +203,50 @@ describe('EpicSidebarComponent', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull();
});
it('renders ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
describe('when sub-epics feature is available', () => {
it('renders ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
store.dispatch('setEpicMeta', {
...mockEpicMeta,
allowSubEpics: false,
});
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.block.ancestors')).toBeNull();
})
.then(done)
.catch(done.fail);
});
});
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
describe('when sub-epics feature is not available', () => {
it('does not render ancestors list', done => {
store.dispatch('toggleSidebarFlag', false);
const reverseAncestors = [...mockAncestors].reverse();
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
const reverseAncestors = [...mockAncestors].reverse();
expect(ancestorsEl).not.toBeNull();
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector));
expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
expect(ancestorsEl).not.toBeNull();
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row').length).toBe(reverseAncestors.length);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
});
});
it('renders participants list element', () => {
......
......@@ -5,9 +5,12 @@ const metaFixture = getJSONFixture('epic/mock_meta.json');
const meta = JSON.parse(metaFixture.meta);
const initial = JSON.parse(metaFixture.initial);
export const mockEpicMeta = convertObjectPropsToCamelCase(meta, {
deep: true,
});
export const mockEpicMeta = {
...convertObjectPropsToCamelCase(meta, {
deep: true,
}),
allowSubEpics: true,
};
export const mockEpicData = convertObjectPropsToCamelCase(
Object.assign({}, getJSONFixture('epic/mock_data.json'), initial, {
......
......@@ -9,6 +9,7 @@ import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_action
import Icon from '~/vue_shared/components/icon.vue';
import {
mockInitialConfig,
mockParentItem,
mockQueryResponse,
} from '../../../javascripts/related_items_tree/mock_data';
......@@ -17,6 +18,7 @@ const createComponent = ({ slots } = {}) => {
const store = createDefaultStore();
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
store.dispatch('setInitialConfig', mockInitialConfig);
store.dispatch('setInitialParentItem', mockParentItem);
store.dispatch('setItemChildren', {
parentItem: mockParentItem,
......@@ -167,13 +169,42 @@ describe('RelatedItemsTree', () => {
expect(badgesContainerEl.isVisible()).toBe(true);
});
it('renders epics count and icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
describe('when sub-epics feature is available', () => {
it('renders epics count and icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon);
expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
expect(epicsEl.text().trim()).toBe('2');
expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic');
});
it('renders `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().isVisible()).toBe(true);
});
});
describe('when sub-epics feature is not available', () => {
beforeEach(() => {
wrapper.vm.$store.commit('SET_INITIAL_CONFIG', {
...mockInitialConfig,
allowSubEpics: false,
});
return wrapper.vm.$nextTick();
});
it('does not render epics count and icon', () => {
const countBadgesEl = wrapper.findAll('.issue-count-badge > span');
const badgeIcon = countBadgesEl.at(0).find(Icon);
expect(countBadgesEl.length).toBe(1);
expect(badgeIcon.props('name')).toBe('issues');
});
it('does not render `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().exists()).toBe(false);
});
});
it('renders issues count and icon', () => {
......@@ -185,10 +216,6 @@ describe('RelatedItemsTree', () => {
expect(issueIcon.props('name')).toBe('issues');
});
it('renders `Add an epic` dropdown button', () => {
expect(findEpicsSplitButton().isVisible()).toBe(true);
});
it('renders `Add an issue` dropdown button', () => {
const addIssueBtn = findAddIssuesButton();
......
......@@ -19,6 +19,7 @@ describe('RelatedItemsTree', () => {
issuesEndpoint: '/bar',
autoCompleteEpics: true,
autoCompleteIssues: false,
allowSubEpics: true,
};
mutations[types.SET_INITIAL_CONFIG](state, data);
......@@ -27,6 +28,7 @@ describe('RelatedItemsTree', () => {
expect(state).toHaveProperty('issuesEndpoint', '/bar');
expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false);
expect(state).toHaveProperty('allowSubEpics', true);
});
});
......
......@@ -7,6 +7,7 @@ export const mockInitialConfig = {
autoCompleteEpics: true,
autoCompleteIssues: false,
userSignedIn: true,
allowSubEpics: true,
};
export const mockParentItem = {
......
......@@ -1451,7 +1451,8 @@ describe Project do
before do
allow(License).to receive(:current).and_return(global_license)
allow(global_license).to receive(:features).and_return([
:epics, # Gold only
:subepics, # Gold only
:epics, # Silver and up
:service_desk, # Silver and up
:audit_events, # Bronze and up
:geo # Global feature, should not be checked at namespace level
......@@ -1477,7 +1478,7 @@ describe Project do
let(:plan_license) { :silver }
it 'filters for silver features' do
is_expected.to contain_exactly(:service_desk, :audit_events, :geo)
is_expected.to contain_exactly(:service_desk, :audit_events, :geo, :epics)
end
end
......@@ -1485,7 +1486,7 @@ describe Project do
let(:plan_license) { :gold }
it 'filters for gold features' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo)
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end
end
......@@ -1502,7 +1503,7 @@ describe Project do
let(:project) { create(:project, :public, group: group) }
it 'includes all features in global license' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo)
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end
end
end
......@@ -1510,7 +1511,7 @@ describe Project do
context 'when namespace should not be checked' do
it 'includes all features in global license' do
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo)
is_expected.to contain_exactly(:epics, :service_desk, :audit_events, :geo, :subepics)
end
end
......
......@@ -45,9 +45,9 @@ describe EpicLinks::DestroyService do
described_class.new(child_epic, user).execute
end
context 'when subepics feature is disabled' do
context 'when epics feature is disabled' do
before do
stub_licensed_features(epics: true, subepics: false)
stub_licensed_features(epics: false)
end
subject { remove_epic_relation(child_epic) }
......@@ -55,9 +55,9 @@ describe EpicLinks::DestroyService do
include_examples 'returns not found error'
end
context 'when subepics feature is enabled' do
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true, subepics: true)
stub_licensed_features(epics: true)
end
context 'when the user has no permissions to remove epic relation' do
......
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