Commit 7bf305dc authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch...

Merge branch '229621-new-epic-button-in-epic-list-should-direct-the-user-to-a-full-epic-creation-page-2' into 'master'

New epic button in Epic Roadmap empty state should direct the user to a full epic creation page

See merge request gitlab-org/gitlab!45948
parents 3082c033 4bb08b91
......@@ -16,13 +16,15 @@ to them.
> - The New Epic form [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211533) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2.
> - In [GitLab 13.7](https://gitlab.com/gitlab-org/gitlab/-/issues/229621) and later, the New Epic button on the Epics list opens the New Epic form.
> - In [GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45948) and later, you can create a new epic from an empty Roadmap.
To create an epic in the group you're in:
1. Get to the New Epic form:
- From the **Epics** list in your group, select the **New Epic** button.
- From an epic in your group, select the **New Epic** button.
- From the **Epics** list in your group, select **New epic**.
- From an epic in your group, select **New epic**.
- From anywhere, in the top menu, select **New...** (**{plus-square}**) **> New epic**.
- In an empty [roadmap](../roadmap/index.md), select **New epic**.
![New epic from an open epic](img/new_epic_from_groups_v13.7.png)
......@@ -39,7 +41,7 @@ To create an epic in the group you're in:
## Edit an epic
After you create an epic, you can edit change the following details:
After you create an epic, you can edit the following details:
- Title
- Description
......@@ -152,6 +154,9 @@ To make an epic confidential:
## Manage issues assigned to an epic
This section collects instructions for all the things you can do with [issues](../../project/issues/index.md)
in relation to epics.
### Add a new issue to an epic
You can add an existing issue to an epic, or create a new issue that's
......
<script>
import { mapState, mapActions } from 'vuex';
import {
GlForm,
GlFormInput,
GlFormCheckbox,
GlIcon,
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlFormCheckbox,
GlIcon,
GlButton,
GlForm,
GlFormInput,
},
directives: {
autofocusonshow,
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
alignRight: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['newEpicTitle', 'newEpicConfidential', 'epicCreateInProgress']),
buttonLabel() {
return this.epicCreateInProgress ? __('Creating epic') : __('Create epic');
},
isEpicCreateDisabled() {
return !this.newEpicTitle.length;
},
epicTitle: {
set(value) {
this.setEpicCreateTitle({
newEpicTitle: value,
});
},
get() {
return this.newEpicTitle;
},
},
epicConfidential: {
set(value) {
this.setEpicCreateConfidential({
newEpicConfidential: value,
});
},
get() {
return this.newEpicConfidential;
},
},
},
methods: {
...mapActions(['setEpicCreateTitle', 'createEpic', 'setEpicCreateConfidential']),
},
};
</script>
<template>
<div class="dropdown epic-create-dropdown">
<gl-button
category="primary"
variant="success"
data-qa-selector="new_epic_button"
data-toggle="dropdown"
>
{{ __('New epic') }}
</gl-button>
<div :class="{ 'dropdown-menu-right': alignRight }" class="dropdown-menu">
<gl-form>
<gl-form-input
ref="epicTitleInput"
v-model="epicTitle"
v-autofocusonshow
:disabled="epicCreateInProgress"
:placeholder="__('Title')"
type="text"
class="form-control"
data-qa-selector="epic_title_field"
@keyup.enter.exact="createEpic"
/>
<gl-form-checkbox
v-model="epicConfidential"
class="mt-3 mb-3 mr-0"
data-qa-selector="confidential_epic_checkbox"
><span> {{ __('Make this epic confidential') }} </span>
<span
v-gl-tooltip.viewport.top.hover
:title="
__(
'This epic and its child elements will only be visible to team members with at minimum Reporter access.',
)
"
:aria-label="
__(
'This epic and its child elements will only be visible to team members with at minimum Reporter access.',
)
"
>
<gl-icon name="question" :size="12"
/></span>
</gl-form-checkbox>
<gl-button
:disabled="isEpicCreateDisabled"
:loading="epicCreateInProgress"
category="primary"
variant="success"
class="gl-mt-3"
data-qa-selector="create_epic_button"
@click.stop="createEpic"
>{{ buttonLabel }}</gl-button
>
</gl-form>
</div>
</div>
</template>
......@@ -9,10 +9,9 @@ import { parseIssuableData } from '~/issue_show/utils/parse_data';
import createStore from './store';
import EpicApp from './components/epic_app.vue';
import EpicCreateApp from './components/epic_create.vue';
export default (epicCreate = false) => {
const el = document.getElementById(epicCreate ? 'epic-create-root' : 'epic-app-root');
export default () => {
const el = document.getElementById('epic-app-root');
if (!el) {
return false;
......@@ -21,28 +20,6 @@ export default (epicCreate = false) => {
const store = createStore();
store.registerModule('labelsSelect', labelsSelectModule());
if (epicCreate) {
return new Vue({
el,
store,
components: { EpicCreateApp },
created() {
this.setEpicMeta({
endpoint: el.dataset.endpoint,
});
},
methods: {
...mapActions(['setEpicMeta']),
},
render: (createElement) =>
createElement('epic-create-app', {
props: {
alignRight: el.dataset.alignRight,
},
}),
});
}
const epicMeta = convertObjectPropsToCamelCase(JSON.parse(el.dataset.meta), { deep: true });
const epicData = parseIssuableData(el);
......
<script>
/* eslint-disable vue/no-v-html */
import { GlButton } from '@gitlab/ui';
import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import CommonMixin from '../mixins/common_mixin';
import { emptyStateDefault, emptyStateWithFilters } from '../constants';
import initEpicCreate from '../../epic/epic_bundle';
export default {
components: {
GlButton,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
mixins: [CommonMixin],
inject: ['newEpicPath', 'listEpicsPath', 'epicsDocsPath'],
props: {
presetType: {
type: String,
......@@ -31,10 +32,6 @@ export default {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
......@@ -96,8 +93,7 @@ export default {
'GroupRoadmap|To view the roadmap, add a start or due date to one of the %{linkStart}child epics%{linkEnd}.',
),
{
linkStart:
'<a href="https://docs.gitlab.com/ee/user/group/epics/#multi-level-child-epics" target="_blank" rel="noopener noreferrer nofollow">',
linkStart: `<a href="${this.epicsDocsPath}#multi-level-child-epics" target="_blank" rel="noopener noreferrer nofollow">`,
linkEnd: '</a>',
},
false,
......@@ -116,36 +112,38 @@ export default {
});
},
},
mounted() {
// If filters are not applied and yet user
// is seeing empty state, we need to show
// `New epic` button, so boot-up Epic app
// in create mode.
if (!this.hasFiltersApplied) {
initEpicCreate(true);
}
},
};
</script>
<template>
<div class="row empty-state">
<div class="col-12">
<div class="svg-content"><img :src="emptyStateIllustrationPath" /></div>
<div class="svg-content">
<img :src="emptyStateIllustrationPath" data-testid="illustration" />
</div>
</div>
<div class="col-12">
<div class="text-content">
<h4>{{ message }}</h4>
<p v-html="subMessage"></p>
<div class="text-center">
<div
<h4 data-testid="title">{{ message }}</h4>
<p v-safe-html="subMessage" data-testid="sub-title"></p>
<div class="gl-text-center">
<gl-button
v-if="!hasFiltersApplied"
id="epic-create-root"
:data-endpoint="newEpicEndpoint"
></div>
<gl-button :title="__('List')" :href="newEpicEndpoint">{{
s__('View epics list')
}}</gl-button>
:href="newEpicPath"
variant="success"
class="gl-mt-3 gl-sm-mt-0! gl-w-full gl-sm-w-auto!"
data-testid="new-epic-button"
>
{{ __('New epic') }}
</gl-button>
<gl-button
:href="listEpicsPath"
class="gl-mt-3 gl-sm-mt-0! gl-sm-ml-3 gl-w-full gl-sm-w-auto!"
data-testid="list-epics-button"
>
{{ __('View epics list') }}
</gl-button>
</div>
</div>
</div>
......
......@@ -37,10 +37,6 @@ export default {
type: String,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
......@@ -179,7 +175,6 @@ export default {
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:has-filters-applied="hasFiltersApplied"
:new-epic-endpoint="newEpicEndpoint"
:empty-state-illustration-path="emptyStateIllustrationPath"
:is-child-epics="isChildEpics"
/>
......
......@@ -50,6 +50,15 @@ export default () => {
components: {
roadmapApp,
},
provide() {
const { dataset } = this.$options.el;
return {
newEpicPath: dataset.newEpicPath,
listEpicsPath: dataset.listEpicsPath,
epicsDocsPath: dataset.epicsDocsPath,
};
},
data() {
const supportedPresetTypes = Object.keys(PRESET_TYPES);
const { dataset } = this.$options.el;
......@@ -83,7 +92,6 @@ export default () => {
basePath: dataset.epicsPath,
fullPath: dataset.fullPath,
epicIid: dataset.iid,
newEpicEndpoint: dataset.newEpicEndpoint,
groupLabelsEndpoint: dataset.groupLabelsEndpoint,
groupMilestonesEndpoint: dataset.groupMilestonesEndpoint,
epicsState: dataset.epicsState,
......@@ -119,7 +127,6 @@ export default () => {
return createElement('roadmap-app', {
props: {
presetType: this.presetType,
newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
});
......
......@@ -66,7 +66,9 @@
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),
new_epic_path: new_group_epic_path(@group),
list_epics_path: group_epics_path(@group),
epics_docs_path: help_page_path('user/group/epics/index'),
preset_type: roadmap_layout,
epics_state: 'all',
sorted_by: roadmap_sort_order,
......
......@@ -20,7 +20,9 @@
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: "#{has_filters_applied}",
new_epic_endpoint: group_epics_path(@group),
new_epic_path: new_group_epic_path(@group),
list_epics_path: group_epics_path(@group),
epics_docs_path: help_page_path('user/group/epics/index'),
group_labels_endpoint: group_labels_path(@group, format: :json),
group_milestones_endpoint: group_milestones_path(@group, format: :json),
preset_type: roadmap_layout,
......
---
title: New epic button in Epic Roadmap empty state should direct the user to a full
epic creation page
merge_request: 45948
author:
type: changed
import Vue from 'vue';
import EpicCreate from 'ee/epic/components/epic_create.vue';
import createStore from 'ee/epic/store';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { mockEpicMeta } from '../mock_data';
describe('EpicCreateComponent', () => {
let vm;
let store;
beforeEach(() => {
const Component = Vue.extend(EpicCreate);
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
vm = mountComponentWithStore(Component, {
store,
});
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('buttonLabel', () => {
it('returns string `Create epic` when `epicCreateInProgress` is false', () => {
vm.$store.state.epicCreateInProgress = false;
expect(vm.buttonLabel).toBe('Create epic');
});
it('returns string `Creating epic` when `epicCreateInProgress` is true', () => {
vm.$store.state.epicCreateInProgress = true;
expect(vm.buttonLabel).toBe('Creating epic');
});
});
describe('isEpicCreateDisabled', () => {
it('returns `true` when `newEpicTitle` is an empty string', () => {
vm.$store.state.newEpicTitle = '';
expect(vm.isEpicCreateDisabled).toBe(true);
});
it('returns `false` when `newEpicTitle` is not empty', () => {
vm.$store.state.newEpicTitle = 'foobar';
expect(vm.isEpicCreateDisabled).toBe(false);
});
});
describe('epicTitle', () => {
describe('set', () => {
it('calls `setEpicCreateTitle` with param `value`', () => {
jest.spyOn(vm, 'setEpicCreateTitle');
const newEpicTitle = 'foobar';
vm.epicTitle = newEpicTitle;
expect(vm.setEpicCreateTitle).toHaveBeenCalledWith(
expect.objectContaining({
newEpicTitle,
}),
);
});
});
describe('get', () => {
it('returns value of `newEpicTitle` from state', () => {
const newEpicTitle = 'foobar';
vm.$store.state.newEpicTitle = newEpicTitle;
expect(vm.epicTitle).toBe(newEpicTitle);
});
});
});
describe('epicConfidential', () => {
describe('set', () => {
it('calls `setEpicCreateConfidential` with param `value`', () => {
jest.spyOn(vm, 'setEpicCreateConfidential');
const newEpicConfidential = true;
vm.epicConfidential = newEpicConfidential;
expect(vm.setEpicCreateConfidential).toHaveBeenCalledWith(
expect.objectContaining({
newEpicConfidential,
}),
);
});
});
describe('get', () => {
it('returns value of `newEpicConfidential` from state', () => {
const newEpicConfidential = true;
vm.$store.state.newEpicConfidential = newEpicConfidential;
expect(vm.epicConfidential).toBe(newEpicConfidential);
});
});
});
});
describe('template', () => {
it('renders component container element with classes `dropdown` & `epic-create-dropdown`', () => {
expect(vm.$el.classList.contains('dropdown')).toBe(true);
expect(vm.$el.classList.contains('epic-create-dropdown')).toBe(true);
});
it('renders new epic button element', () => {
const newEpicButtonEl = vm.$el.querySelector('button.btn-success');
expect(newEpicButtonEl).not.toBeNull();
expect(newEpicButtonEl.innerText.trim()).toBe('New epic');
});
it('renders new epic dropdown menu element', () => {
const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu');
expect(dropdownMenuEl).not.toBeNull();
});
it('renders epic input textbox element', () => {
const inputEl = vm.$el.querySelector('.dropdown-menu input.form-control');
expect(inputEl).not.toBeNull();
expect(inputEl.placeholder).toBe('Title');
});
it('renders create epic button element', () => {
const createEpicButtonEl = vm.$el.querySelector('.dropdown-menu button.btn-success');
expect(createEpicButtonEl).not.toBeNull();
expect(createEpicButtonEl.innerText.trim()).toBe('Create epic');
});
});
});
......@@ -17,7 +17,6 @@ import {
mockFormattedEpic,
mockFormattedChildEpic2,
mockGroupId,
mockNewEpicEndpoint,
mockSortedBy,
mockSvgPath,
mockTimeframeInitialDate,
......@@ -35,7 +34,6 @@ describe('RoadmapApp', () => {
const emptyStateIllustrationPath = mockSvgPath;
const epics = [mockFormattedEpic];
const hasFiltersApplied = true;
const newEpicEndpoint = mockNewEpicEndpoint;
const presetType = PRESET_TYPES.MONTHS;
const timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate);
......@@ -44,7 +42,6 @@ describe('RoadmapApp', () => {
localVue,
propsData: {
emptyStateIllustrationPath,
newEpicEndpoint,
presetType,
},
provide: {
......@@ -122,10 +119,6 @@ describe('RoadmapApp', () => {
expect(wrapper.find(EpicsListEmpty).props('isChildEpics')).toBe(false);
});
it('contains endpoint to create a new epic', () => {
expect(wrapper.find(EpicsListEmpty).props('newEpicEndpoint')).toBe(mockNewEpicEndpoint);
});
it('contains the preset type', () => {
expect(wrapper.find(EpicsListEmpty).props('presetType')).toBe(presetType);
});
......
......@@ -17223,9 +17223,6 @@ msgstr ""
msgid "Make sure you save it - you won't be able to access it again."
msgstr ""
msgid "Make this epic confidential"
msgstr ""
msgid "Makes this issue confidential."
msgstr ""
......@@ -29039,9 +29036,6 @@ msgstr ""
msgid "This epic already has the maximum number of child epics."
msgstr ""
msgid "This epic and its child elements will only be visible to team members with at minimum Reporter access."
msgstr ""
msgid "This epic does not exist or you don't have sufficient permission."
msgstr ""
......
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