Commit 9543795f authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '211533-new-epic-button-within-epic-page' into 'master'

Add "New epic" button within epic page

See merge request gitlab-org/gitlab!34109
parents 0290c2d3 e8777da4
......@@ -113,3 +113,11 @@
.gl-top-66vh {
top: 66vh;
}
// Remove when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/871
// gets fixed on GitLab UI
.gl-sm-w-auto\! {
@media (min-width: $breakpoint-sm) {
width: auto !important;
}
}
......@@ -14,8 +14,15 @@ Epics let you manage your portfolio of projects more efficiently and with less
effort by tracking groups of issues that share a theme, across projects and
milestones.
<!-- Possibly swap this file with one of a single epic -->
![epics list view](img/epics_list_view_v12.5.png)
An epic's page contains the following tabs:
- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are
shown in a tree view.
- Click the chevron (**>**) next to a parent epic to reveal the child epics and issues.
- Hover over the total counts to see a breakdown of open and closed items.
- **Roadmap**: a roadmap view of child epics which have start and due dates.
![epic view](img/epic_view_v13.0.png)
## Use cases
......@@ -28,6 +35,7 @@ milestones.
To learn what you can do with an epic, see [Manage epics](manage_epics.md). Possible actions include:
- [Create an epic](manage_epics.md#create-an-epic)
- [Edit an epic](manage_epics.md#edit-an-epic)
- [Bulk-edit epics](../bulk_editing/index.md#bulk-edit-epics)
- [Delete an epic](manage_epics.md#delete-an-epic)
- [Close an epic](manage_epics.md#close-an-epic)
......
......@@ -18,12 +18,42 @@ A paginated list of epics is available in each group from where you can create
a new epic. The list of epics includes also epics from all subgroups of the
selected group. From your group page:
1. Go to **Epics**.
### Create an epic from the epic list
To create an epic from the epic list, in a group:
1. Go to **{epic}** **Epics**.
1. Click **New epic**.
1. Enter a descriptive title.
1. Click **Create epic**.
You will be taken to the new epic where can edit the following details:
### Access the New Epic form
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211533) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
There are two ways to get to the New Epic form and create an epic in the group you're in:
- From an epic in your group, click **New Epic**.
- From anywhere, in the top menu, click **plus** (**{plus-square}**) **> New epic**.
![New epic from an open epic](img/new_epic_from_groups_v13.2.png)
### Elements of the New Epic form
When you're creating a new epic, these are the fields you can fill in:
- Title
- Description
- Confidentiality checkbox
- Labels
- Start date
- Due date
![New epic form](img/new_epic_form_v13.2.png)
## Edit an epic
After you create an epic, you can edit change the following details:
- Title
- Description
......@@ -31,15 +61,16 @@ You will be taken to the new epic where can edit the following details:
- Due date
- Labels
An epic's page contains the following tabs:
To edit an epic's title or description:
1. Click the **Edit title and description** **{pencil}** button.
1. Make your changes.
1. Click **Save changes**.
- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are
shown in a tree view.
- Click the <kbd>></kbd> beside a parent epic to reveal the child epics and issues.
- Hover over the total counts to see a breakdown of open and closed items.
- **Roadmap**: a roadmap view of child epics which have start and due dates.
To edit an epics' start date, due date, or labels:
![epic view](img/epic_view_v13.0.png)
1. Click **Edit** next to each section in the epic sidebar.
1. Select the dates or labels for your epic.
## Bulk-edit epics
......
......@@ -45,8 +45,7 @@ There are many ways to get to the New Issue form from within a project:
### Elements of the New Issue form
> Ability to add the new issue to an epic [was introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13847)
> in [GitLab Premium](https://about.gitlab.com/pricing/) 13.1.
> Ability to add the new issue to an epic [was introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13847) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.1.
![New issue from the issues list](img/new_issue_v13_1.png)
......
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import epicUtils from '../utils/epic_utils';
import { statusType } from '../constants';
......@@ -17,12 +18,13 @@ export default {
},
components: {
GlIcon,
GlDeprecatedButton,
GlButton,
UserAvatarLink,
TimeagoTooltip,
GitlabTeamMemberBadge: () =>
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState([
'sidebarCollapsed',
......@@ -30,8 +32,10 @@ export default {
'epicStatusChangeInProgress',
'author',
'created',
'canCreate',
'canUpdate',
'confidential',
'newEpicWebUrl',
]),
...mapGetters(['isEpicOpen']),
statusIcon() {
......@@ -41,16 +45,14 @@ export default {
return this.isEpicOpen ? __('Open') : __('Closed');
},
actionButtonClass() {
// False positive css classes
// https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/24
// eslint-disable-next-line @gitlab/require-i18n-strings
return `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button ${
this.isEpicOpen ? 'btn-close' : 'btn-open'
}`;
return this.isEpicOpen ? 'btn-close' : 'btn-open';
},
actionButtonText() {
return this.isEpicOpen ? __('Close epic') : __('Reopen epic');
},
userCanCreate() {
return this.canCreate && this.glFeatures.createEpicForm;
},
},
mounted() {
/**
......@@ -76,17 +78,22 @@ export default {
</script>
<template>
<div class="detail-page-header">
<div class="detail-page-header gl-flex-wrap gl-py-3">
<div class="detail-page-header-body">
<div
:class="{ 'status-box-open': isEpicOpen, 'status-box-issue-closed': !isEpicOpen }"
class="issuable-status-box status-box"
data-testid="status-box"
>
<gl-icon :name="statusIcon" class="d-block d-sm-none" />
<span class="d-none d-sm-block">{{ statusText }}</span>
<gl-icon :name="statusIcon" class="d-block d-sm-none" data-testid="status-icon" />
<span class="d-none d-sm-block" data-testid="status-text">{{ statusText }}</span>
</div>
<div class="issuable-meta">
<div v-if="confidential" class="issuable-warning-icon inline">
<div class="issuable-meta" data-testid="author-details">
<div
v-if="confidential"
class="issuable-warning-icon inline"
data-testid="confidential-icon"
>
<gl-icon name="eye-slash" class="icon" />
</div>
{{ __('Opened') }}
......@@ -108,22 +115,41 @@ export default {
</strong>
</div>
</div>
<div v-if="canUpdate" class="detail-page-header-actions js-issuable-actions">
<gl-deprecated-button
:loading="epicStatusChangeInProgress"
:class="actionButtonClass"
@click="toggleEpicStatus(isEpicOpen)"
>{{ actionButtonText }}</gl-deprecated-button
>
</div>
<gl-deprecated-button
<gl-button
:aria-label="__('Toggle sidebar')"
variant="secondary"
class="float-right d-block d-sm-none gutter-toggle issuable-gutter-toggle js-sidebar-toggle"
type="button"
class="float-right gl-display-block d-sm-none gl-align-self-center gutter-toggle issuable-gutter-toggle"
data-testid="sidebar-toggle"
@click="toggleSidebar({ sidebarCollapsed })"
>
<i class="fa fa-angle-double-left"></i>
</gl-deprecated-button>
</gl-button>
<div
class="detail-page-header-actions gl-display-flex gl-flex-wrap gl-align-items-center gl-w-full gl-sm-w-auto!"
data-testid="action-buttons"
>
<gl-button
v-if="canUpdate"
:loading="epicStatusChangeInProgress"
:class="actionButtonClass"
category="secondary"
variant="warning"
class="qa-close-reopen-epic-button gl-mt-3 gl-sm-mt-0! gl-w-full gl-sm-w-auto!"
data-testid="toggle-status-button"
@click="toggleEpicStatus(isEpicOpen)"
>
{{ actionButtonText }}
</gl-button>
<gl-button
v-if="userCanCreate"
:href="newEpicWebUrl"
category="secondary"
variant="success"
class="gl-mt-3 gl-sm-mt-0! gl-sm-ml-3 gl-w-full gl-sm-w-auto!"
data-testid="new-epic-button"
>
{{ __('New epic') }}
</gl-button>
</div>
</div>
</template>
......@@ -14,8 +14,10 @@ export default () => ({
epicsWebUrl: '',
labelsWebUrl: '',
markdownDocsPath: '',
newEpicWebUrl: '',
// Flags
canCreate: false,
canUpdate: false,
canDestroy: false,
canAdmin: false,
......
......@@ -72,7 +72,6 @@
}
.detail-page-header-actions {
width: auto;
margin-top: 0;
}
}
......
......@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_epic_sidebar, @group)
push_frontend_feature_flag(:confidential_epics, @group, default_enabled: true)
push_frontend_feature_flag(:create_epic_form, @group, default_enabled: true)
end
def new; end
......
# frozen_string_literal: true
module EpicsHelper
def epic_initial_data(epic)
issuable_initial_data(epic).merge(canCreate: can?(current_user, :create_epic, epic.group))
end
def epic_show_app_data(epic)
EpicPresenter.new(epic, current_user: current_user).show_data(author_icon: avatar_icon_for_user(epic.author), base_data: issuable_initial_data(epic))
EpicPresenter.new(epic, current_user: current_user).show_data(author_icon: avatar_icon_for_user(epic.author), base_data: epic_initial_data(epic))
end
def epic_new_app_data(group)
......
......@@ -78,7 +78,8 @@ class EpicPresenter < Gitlab::View::Presenter::Delegated
labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
toggle_subscription_path: toggle_subscription_group_epic_path(group, epic),
labels_web_url: group_labels_path(group),
epics_web_url: group_epics_path(group)
epics_web_url: group_epics_path(group),
new_epic_web_url: new_group_epic_path(group)
}
paths[:todo_delete_path] = dashboard_todo_path(epic_pending_todo) if epic_pending_todo.present?
......
---
title: Add "New epic" button within epic page
merge_request: 34109
author:
type: added
......@@ -4,6 +4,7 @@
"properties": {
"labels": {},
"participants": {},
"subscribed": {}
"subscribed": {},
"canCreate": false
}
}
......@@ -3,7 +3,7 @@
"type": "object",
"required": ["epic_id", "created", "author", "ancestors", "todo_exists", "todo_path", "lock_version",
"state", "namespace", "labels_path", "toggle_subscription_path", "labels_web_url", "epics_web_url",
"scoped_labels", "start_date", "start_date_is_fixed", "start_date_fixed",
"new_epic_web_url", "scoped_labels", "start_date", "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", "due_date_sourcing_milestone_dates"],
......@@ -100,6 +100,9 @@
"epics_web_url": {
"type": "string"
},
"new_epic_web_url": {
"type": "string"
},
"scoped_labels": {
"type": "boolean"
},
......
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
......@@ -11,13 +9,10 @@ import { statusType } from 'ee/epic/constants';
import { mockEpicMeta, mockEpicData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EpicHeaderComponent', () => {
let wrapper;
let vm;
let store;
let features = {};
beforeEach(() => {
store = createStore();
......@@ -25,135 +20,178 @@ describe('EpicHeaderComponent', () => {
store.dispatch('setEpicData', mockEpicData);
wrapper = shallowMount(EpicHeader, {
localVue,
store,
provide: {
glFeatures: features,
},
});
vm = wrapper.vm;
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findStatusBox = () => wrapper.find('[data-testid="status-box"]');
const findStatusIcon = () => wrapper.find('[data-testid="status-icon"]');
const findStatusText = () => wrapper.find('[data-testid="status-text"]');
const findConfidentialIcon = () => wrapper.find('[data-testid="confidential-icon"]').find(GlIcon);
const findAuthorDetails = () => wrapper.find('[data-testid="author-details"]');
const findActionButtons = () => wrapper.find('[data-testid="action-buttons"]');
const findToggleStatusButton = () => wrapper.find('[data-testid="toggle-status-button"]');
const findNewEpicButton = () => wrapper.find('[data-testid="new-epic-button"]');
const findSidebarToggle = () => wrapper.find('[data-testid="sidebar-toggle"]');
describe('computed', () => {
describe('statusIcon', () => {
it('returns string `issue-open-m` when `isEpicOpen` is true', () => {
vm.$store.state.state = statusType.open;
store.state.state = statusType.open;
expect(vm.statusIcon).toBe('issue-open-m');
expect(findStatusIcon().props('name')).toBe('issue-open-m');
});
it('returns string `mobile-issue-close` when `isEpicOpen` is false', () => {
vm.$store.state.state = statusType.close;
store.state.state = statusType.close;
expect(vm.statusIcon).toBe('mobile-issue-close');
return wrapper.vm.$nextTick().then(() => {
expect(findStatusIcon().props('name')).toBe('mobile-issue-close');
});
});
});
describe('statusText', () => {
it('returns string `Open` when `isEpicOpen` is true', () => {
vm.$store.state.state = statusType.open;
store.state.state = statusType.open;
expect(vm.statusText).toBe('Open');
expect(findStatusText().text()).toBe('Open');
});
it('returns string `Closed` when `isEpicOpen` is false', () => {
vm.$store.state.state = statusType.close;
store.state.state = statusType.close;
expect(vm.statusText).toBe('Closed');
return wrapper.vm.$nextTick().then(() => {
expect(findStatusText().text()).toBe('Closed');
});
});
});
describe('actionButtonClass', () => {
it('returns default button classes along with `btn-close` when `isEpicOpen` is true', () => {
vm.$store.state.state = statusType.open;
it('returns `btn-close` when `isEpicOpen` is true', () => {
store.state.state = statusType.open;
expect(vm.actionButtonClass).toBe(
'btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button btn-close',
);
expect(findToggleStatusButton().classes()).toContain('btn-close');
});
it('returns default button classes along with `btn-open` when `isEpicOpen` is false', () => {
vm.$store.state.state = statusType.close;
it('returns `btn-open` when `isEpicOpen` is false', () => {
store.state.state = statusType.close;
expect(vm.actionButtonClass).toBe(
'btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button btn-open',
);
return wrapper.vm.$nextTick().then(() => {
expect(findToggleStatusButton().classes()).toContain('btn-open');
});
});
});
describe('actionButtonText', () => {
it('returns string `Close epic` when `isEpicOpen` is true', () => {
vm.$store.state.state = statusType.open;
store.state.state = statusType.open;
expect(vm.actionButtonText).toBe('Close epic');
expect(findToggleStatusButton().text()).toBe('Close epic');
});
it('returns string `Reopen epic` when `isEpicOpen` is false', () => {
vm.$store.state.state = statusType.close;
store.state.state = statusType.close;
expect(vm.actionButtonText).toBe('Reopen epic');
return wrapper.vm.$nextTick().then(() => {
expect(findToggleStatusButton().text()).toBe('Reopen epic');
});
});
});
});
describe('template', () => {
it('renders component container element with class `detail-page-header`', () => {
expect(vm.$el.classList.contains('detail-page-header')).toBe(true);
expect(vm.$el.querySelector('.detail-page-header-body')).not.toBeNull();
expect(wrapper.classes()).toContain('detail-page-header');
expect(wrapper.find('.detail-page-header-body').exists()).toBeTruthy();
});
it('renders epic status icon and text elements', () => {
const statusEl = wrapper.find('.issuable-status-box');
const statusBox = findStatusBox();
expect(statusEl.exists()).toBe(true);
expect(statusEl.find(GlIcon).props('name')).toBe('issue-open-m');
expect(statusEl.find('span').text()).toBe('Open');
expect(statusBox.exists()).toBe(true);
expect(statusBox.find(GlIcon).props('name')).toBe('issue-open-m');
expect(statusBox.find('span').text()).toBe('Open');
});
it('renders confidential icon when `confidential` prop is true', () => {
vm.$store.state.confidential = true;
store.state.confidential = true;
return wrapper.vm.$nextTick(() => {
const confidentialIcon = findConfidentialIcon();
return Vue.nextTick(() => {
const iconEl = wrapper.find('.issuable-warning-icon').find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('eye-slash');
expect(confidentialIcon.exists()).toBe(true);
expect(confidentialIcon.props('name')).toBe('eye-slash');
});
});
it('renders epic author details element', () => {
const metaEl = wrapper.find('.issuable-meta');
const epicDetails = findAuthorDetails();
expect(metaEl.exists()).toBe(true);
expect(metaEl.find(TimeagoTooltip).exists()).toBe(true);
expect(metaEl.find(UserAvatarLink).exists()).toBe(true);
expect(epicDetails.exists()).toBe(true);
expect(epicDetails.find(TimeagoTooltip).exists()).toBe(true);
expect(epicDetails.find(UserAvatarLink).exists()).toBe(true);
});
it('renders action buttons element', () => {
const actionsEl = vm.$el.querySelector('.js-issuable-actions');
const actionButtons = findActionButtons();
const toggleStatusButton = findToggleStatusButton();
expect(actionsEl).not.toBeNull();
expect(actionsEl.querySelector('.js-btn-epic-action')).not.toBeNull();
expect(actionsEl.querySelector('.js-btn-epic-action').innerText.trim()).toBe('Close epic');
expect(actionButtons.exists()).toBeTruthy();
expect(toggleStatusButton.exists()).toBeTruthy();
expect(toggleStatusButton.text()).toBe('Close epic');
});
it('renders toggle sidebar button element', () => {
const toggleButtonEl = wrapper.find('.js-sidebar-toggle');
const toggleButton = findSidebarToggle();
expect(toggleButtonEl.exists()).toBe(true);
expect(toggleButtonEl.attributes('aria-label')).toBe('Toggle sidebar');
expect(toggleButtonEl.classes()).toEqual(
expect(toggleButton.exists()).toBeTruthy();
expect(toggleButton.attributes('aria-label')).toBe('Toggle sidebar');
expect(toggleButton.classes()).toEqual(
expect.arrayContaining([('d-block', 'd-sm-none', 'gutter-toggle')]),
);
});
it('renders GitLab team member badge when `author.isGitlabEmployee` is `true`', () => {
vm.$store.state.author.isGitlabEmployee = true;
store.state.author.isGitlabEmployee = true;
// Wait for dynamic imports to resolve
return new Promise(setImmediate).then(() => {
expect(vm.$refs.gitlabTeamMemberBadge).not.toBeUndefined();
expect(wrapper.vm.$refs.gitlabTeamMemberBadge).not.toBeUndefined();
});
});
it('does not render new epic button without `createEpicForm` feature flag', () => {
expect(findNewEpicButton().exists()).toBeFalsy();
});
describe('with `createEpicForm` feature flag', () => {
beforeAll(() => {
features = { createEpicForm: true };
});
it('does not render new epic button if user cannot create it', () => {
store.state.canCreate = false;
return wrapper.vm.$nextTick().then(() => {
expect(findNewEpicButton().exists()).toBe(false);
});
});
it('renders new epic button if user can create it', () => {
store.state.canCreate = true;
return wrapper.vm.$nextTick().then(() => {
expect(findNewEpicButton().exists()).toBe(true);
});
});
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment