Commit a9f9b4b4 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '353170-add-related-epics-frontend' into 'master'

Add Related Epics support within epics

See merge request gitlab-org/gitlab!81119
parents fd2ad8c8 1441a8e3
...@@ -210,6 +210,7 @@ export default { ...@@ -210,6 +210,7 @@ export default {
<related-issues-list <related-issues-list
v-for="category in categorisedIssues" v-for="category in categorisedIssues"
:key="category.linkType" :key="category.linkType"
:list-link-type="category.linkType"
:heading="$options.linkedIssueTypesTextMap[category.linkType]" :heading="$options.linkedIssueTypesTextMap[category.linkType]"
:can-admin="canAdmin" :can-admin="canAdmin"
:can-reorder="canReorder" :can-reorder="canReorder"
......
...@@ -21,6 +21,11 @@ export default { ...@@ -21,6 +21,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
listLinkType: {
type: String,
required: false,
default: '',
},
heading: { heading: {
type: String, type: String,
required: false, required: false,
...@@ -91,7 +96,7 @@ export default { ...@@ -91,7 +96,7 @@ export default {
</script> </script>
<template> <template>
<div> <div :data-link-type="listLinkType">
<h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4> <h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4>
<div <div
class="related-issues-token-body bordered-box bg-white" class="related-issues-token-body bordered-box bg-white"
......
...@@ -62,6 +62,7 @@ You can also consult the [group permissions table](../../permissions.md#group-me ...@@ -62,6 +62,7 @@ You can also consult the [group permissions table](../../permissions.md#group-me
## Related topics ## Related topics
- [Manage epics](manage_epics.md) and multi-level child epics. - [Manage epics](manage_epics.md) and multi-level child epics.
- Link [related epics](linked_epics.md) based on a type of relationship.
- Create workflows with [epic boards](epic_boards.md). - Create workflows with [epic boards](epic_boards.md).
- [Turn on notifications](../../profile/notifications.md) for about epic events. - [Turn on notifications](../../profile/notifications.md) for about epic events.
- [Award an emoji](../../award_emojis.md) to an epic or its comments. - [Award an emoji](../../award_emojis.md) to an epic or its comments.
......
---
stage: Plan
group: Product Planning
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Linked epics **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/353473) in GitLab 14.9 [with a flag](../../../administration/feature_flags.md) named `related_epics_widget`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available,
ask an administrator to [enable the feature flag](../../../administration/feature_flags.md)
named `related_epics_widget`. On GitLab.com, this feature is not available.
Linked epics are a bi-directional relationship between any two epics and appear in a block below
the epic description. You can link epics in different groups.
The relationship only shows up in the UI if the user can see both epics. When you try to close an
epic that has open blockers, a warning is displayed.
NOTE:
To manage linked epics through our API, visit the [epic links API documentation](../../../api/linked_epics.md).
## Add a linked epic
Prerequisites:
- You must have at least the Reporter role for both groups.
- For GitLab SaaS: the epic that you're editing must be in a group on GitLab Ultimate.
The epics you're linking can be in a group on a lower tier.
To link one epic to another:
1. In the **Linked epics** section of an epic,
select the add linked epic button (**{plus}**).
1. Select the relationship between the two epics. Either:
- **relates to**
- **[blocks](#blocking-epics)**
- **[is blocked by](#blocking-epics)**
1. Enter the epic number or paste in the full URL of the epic.
![Adding a related epic](img/related_epics_add_v14_9.png)
Epics of the same group can be specified just by the reference number.
Epics from a different group require additional information like the
group name. For example:
- The same group: `&44`
- Different group: `group&44`
Valid references are added to a temporary list that you can review.
1. Select **Add**.
The linked epics are then displayed on the epic grouped by relationship.
![Related epic block](img/related_epic_block_v14_9.png)
## Remove a linked epic
Prerequisites:
- You must have at least the Reporter role for the epic's group.
To remove a linked epic, in the **Linked epics** section of an epic,
select **Remove** (**{close}**) next to
each epic.
The relationship is removed from both epics.
![Removing a related epic](img/related_epics_remove_v14_9.png)
## Blocking epics
When you [add a linked epic](#add-a-linked-epic), you can show that it **blocks** or
**is blocked by** another epic.
If you try to close a blocked epic using the "Close epic" button, a confirmation message appears.
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedEpicsRoot from '~/related_issues/components/related_issues_root.vue';
import { PathIdSeparator, issuableTypesMap } from '~/related_issues/constants';
export default function initRelatedEpics() {
const relatedEpicsRootEl = document.querySelector('#js-related-epics');
if (relatedEpicsRootEl) {
const {
endpoint,
canAddRelatedEpics,
helpPath,
showCategorizedEpics,
} = relatedEpicsRootEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: relatedEpicsRootEl,
name: 'LinkedEpicsRoot',
components: {
RelatedEpicsRoot,
},
render: (createElement) =>
createElement('related-epics-root', {
props: {
endpoint,
helpPath,
canAdmin: parseBoolean(canAddRelatedEpics),
showCategorizedIssues: parseBoolean(showCategorizedEpics),
pathIdSeparator: PathIdSeparator.Epic,
issuableType: issuableTypesMap.EPIC,
},
}),
});
}
}
...@@ -7,6 +7,12 @@ import ZenMode from '~/zen_mode'; ...@@ -7,6 +7,12 @@ import ZenMode from '~/zen_mode';
initNotesApp(); initNotesApp();
initEpicApp(); initEpicApp();
if (gon.features.relatedEpicsWidget) {
import('ee/linked_epics/linked_epics_bundle')
.then((m) => m.default())
.catch(() => {});
}
requestIdleCallback(() => { requestIdleCallback(() => {
const awardEmojiEl = document.getElementById('js-vue-awards-block'); const awardEmojiEl = document.getElementById('js-vue-awards-block');
......
...@@ -18,6 +18,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -18,6 +18,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:improved_emoji_picker, @group, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, @group, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:related_epics_widget, @group, type: :development, default_enabled: :yaml)
end end
feature_category :portfolio_management feature_category :portfolio_management
......
...@@ -71,6 +71,13 @@ ...@@ -71,6 +71,13 @@
sorted_by: roadmap_sort_order, sorted_by: roadmap_sort_order,
inner_height: '600', inner_height: '600',
child_epics: 'true' } } child_epics: 'true' } }
- if Feature.enabled?(:related_epics_widget, @group, default_enabled: :yaml)
#js-related-epics{ data: { endpoint: group_epic_related_epic_links_path(@group, @epic),
can_add_related_epics: "#{can?(current_user, :admin_related_epic_link, @epic)}",
help_path: help_page_path('user/group/epics/linked_epics'),
show_categorized_epics: 'true' } }
%hr.epic-discussion-separator.mt-1.mb-0 %hr.epic-discussion-separator.mt-1.mb-0
.d-flex.justify-content-between.content-block.content-block-small.emoji-list-container.js-noteable-awards .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, api_awards_path: award_emoji_epics_api_path(@epic) = render 'award_emoji/awards_block', awardable: @epic, inline: true, api_awards_path: award_emoji_epics_api_path(@epic)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Related Epics', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:epic1) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group) }
let_it_be(:epic3) { create(:epic, group: group) }
def visit_epic
group.add_developer(user)
stub_licensed_features(epics: true, related_epics: true)
sign_in(user)
visit group_epic_path(group, epic1)
wait_for_requests
find('.js-epic-tree-tab').click
wait_for_requests
end
def open_add_epic_form
page.within('.js-epic-container .card-title') do
page.find('button').click
end
end
def add_epic(epic, relationship)
page.within('.js-add-related-issues-form-area') do
page.find('#add-related-issues-form-input').native.send_keys("&#{epic.iid} ")
page.find("input[value='#{relationship}']").click
click_button 'Add'
wait_for_requests
end
end
before do
stub_feature_flags(related_epics_widget: true)
visit_epic
end
describe 'epic body section' do
it 'user can view related epics section under epic description', :aggregate_failures do
page.within('.js-epic-container') do
expect(page).to have_selector('#related-issues')
card_title = page.find('.card-title')
expect(card_title).to have_content('Linked epics')
expect(card_title).to have_link('', href: '/help/user/group/epics/linked_epics')
expect(card_title).to have_selector('button')
end
end
end
describe 'related epics add epic form' do
before do
open_add_epic_form
end
it 'user can view category selection radio inputs', :aggregate_failures do
page.within('.js-add-related-issues-form-area') do
expect(page.find('label[for="linked-issue-type-radio"]')).to have_content('The current epic')
page.within('#linked-issue-type-radio') do
[
'relates to',
'blocks',
'is blocked by'
].each_with_index do |category, index|
expect(page.find(".gl-form-radio:nth-child(#{index + 1})")).to have_content(category)
end
end
end
end
it 'user can view epic input field', :aggregate_failures do
page.within('.js-add-related-issues-form-area') do
expect(page.find('p')).to have_content('the following epic(s)')
expect(page).to have_selector('.add-issuable-form-input-wrapper')
end
end
it 'epic input field can autocomplete epics when `&` is input', :aggregate_failures do
page.within('.js-add-related-issues-form-area') do
page.find('#add-related-issues-form-input').native.send_keys('&')
end
expect(page).to have_selector('#at-view-epics')
expect(page.find('#at-view-epics')).to have_selector('li', count: 3)
[epic3, epic2, epic1].each_with_index do |epic, index|
expect(page.find("#at-view-epics li:nth-child(#{index + 1})")).to have_content("&#{epic.iid} #{epic.title}")
end
end
it 'user can view list of added epics as tokens within input field', :aggregate_failures do
page.within('.js-add-related-issues-form-area .add-issuable-form-input-wrapper') do
page.find('#add-related-issues-form-input').native.send_keys("&#{epic1.iid} ")
expect(page.find('.issue-token')).to have_content("&#{epic1.iid}")
expect(page).to have_selector('button.issue-token-remove-button')
end
end
end
describe 'related epics list' do
it 'user can add an epic with selected relationship type', :aggregate_failures do
relationship_types = %w[blocks is_blocked_by relates_to]
list_headings = ['Blocks', 'Is blocked by', 'Relates to']
relationship_types.each_with_index do |relationship, index|
temp_epic = create(:epic, group: group)
open_add_epic_form
add_epic(temp_epic, relationship)
page.within("div[data-link-type='#{relationship}']") do
expect(page.find('h4')).to have_content(list_headings[index])
expect(page.find('ul.related-items-list')).to have_selector('li', count: 1)
expect(page.find('ul.related-items-list li')).to have_content(temp_epic.title)
expect(page.find('ul.related-items-list li')).to have_selector('button.js-issue-item-remove-button')
end
end
end
it 'user can remove an epic from the list', :aggregate_failures do
open_add_epic_form
add_epic(epic2, 'relates_to')
page.within('div[data-link-type="relates_to"]') do
page.find('button.js-issue-item-remove-button').click
end
wait_for_requests
expect(page).not_to have_selector('div[data-link-type="relates_to"]')
end
end
end
...@@ -30,6 +30,7 @@ RSpec.describe 'Epic show', :js do ...@@ -30,6 +30,7 @@ RSpec.describe 'Epic show', :js do
before do before do
group.add_developer(user) group.add_developer(user)
stub_licensed_features(epics: true, subepics: true) stub_licensed_features(epics: true, subepics: true)
stub_feature_flags(related_epics_widget: false)
sign_in(user) sign_in(user)
end end
...@@ -180,6 +181,12 @@ RSpec.describe 'Epic show', :js do ...@@ -180,6 +181,12 @@ RSpec.describe 'Epic show', :js do
end end
end end
it 'does not show related epics section when related_epics_widget feature flag is not enabled' do
page.within('.js-epic-container') do
expect(page).not_to have_selector('#related-issues')
end
end
it 'shows epic thread filter dropdown' do it 'shows epic thread filter dropdown' do
page.within('.js-noteable-awards') do page.within('.js-noteable-awards') do
expect(find('#discussion-filter-dropdown')).to have_content('Show all activity') expect(find('#discussion-filter-dropdown')).to have_content('Show all activity')
......
...@@ -28,11 +28,16 @@ describe('RelatedIssuesList', () => { ...@@ -28,11 +28,16 @@ describe('RelatedIssuesList', () => {
propsData: { propsData: {
pathIdSeparator: PathIdSeparator.Issue, pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue', issuableType: 'issue',
listLinkType: 'relates_to',
heading, heading,
}, },
}); });
}); });
it('assigns value of listLinkType prop to data attribute', () => {
expect(wrapper.attributes('data-link-type')).toBe('relates_to');
});
it('shows a heading', () => { it('shows a heading', () => {
expect(wrapper.find('h4').text()).toContain(heading); expect(wrapper.find('h4').text()).toContain(heading);
}); });
......
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