Commit 3122ee2e authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '10966-new-create-epic-page' into 'master'

Add new Epic page

See merge request gitlab-org/gitlab!32701
parents ec3d36f4 bbd2ce88
export const DropdownVariant = { export const DropdownVariant = {
Sidebar: 'sidebar', Sidebar: 'sidebar',
Standalone: 'standalone', Standalone: 'standalone',
Embedded: 'embedded',
}; };
export const LIST_BUFFER_SIZE = 5; export const LIST_BUFFER_SIZE = 5;
...@@ -8,12 +8,16 @@ export default { ...@@ -8,12 +8,16 @@ export default {
GlIcon, GlIcon,
}, },
computed: { computed: {
...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']), ...mapGetters([
'dropdownButtonText',
'isDropdownVariantStandalone',
'isDropdownVariantEmbedded',
]),
}, },
methods: { methods: {
...mapActions(['toggleDropdownContents']), ...mapActions(['toggleDropdownContents']),
handleButtonClick(e) { handleButtonClick(e) {
if (this.isDropdownVariantStandalone) { if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents(); this.toggleDropdownContents();
e.stopPropagation(); e.stopPropagation();
} }
......
...@@ -88,12 +88,16 @@ export default { ...@@ -88,12 +88,16 @@ export default {
@click.prevent="handleColorClick(color)" @click.prevent="handleColorClick(color)"
/> />
</div> </div>
<div class="color-input-container d-flex"> <div class="color-input-container gl-display-flex">
<span <span
class="dropdown-label-color-preview position-relative position-relative d-inline-block" class="dropdown-label-color-preview position-relative position-relative d-inline-block"
:style="{ backgroundColor: selectedColor }" :style="{ backgroundColor: selectedColor }"
></span> ></span>
<gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" /> <gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:placeholder="__('Use custom color #FF0000')"
/>
</div> </div>
</div> </div>
<div class="dropdown-actions clearfix pt-2 px-2"> <div class="dropdown-actions clearfix pt-2 px-2">
......
...@@ -36,7 +36,7 @@ export default { ...@@ -36,7 +36,7 @@ export default {
'footerCreateLabelTitle', 'footerCreateLabelTitle',
'footerManageLabelTitle', 'footerManageLabelTitle',
]), ]),
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']), ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() { visibleLabels() {
if (this.searchKey) { if (this.searchKey) {
return this.labels.filter(label => return this.labels.filter(label =>
...@@ -126,16 +126,19 @@ export default { ...@@ -126,16 +126,19 @@ export default {
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<gl-loading-icon <gl-loading-icon
v-if="labelsFetchInProgress" v-if="labelsFetchInProgress"
class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100" class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100"
size="md" size="md"
/> />
<div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2"> <div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
>
<span class="flex-grow-1">{{ labelsListTitle }}</span> <span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button <gl-button
:aria-label="__('Close')" :aria-label="__('Close')"
variant="link" variant="link"
size="small" size="small"
class="dropdown-header-button p-0" class="dropdown-header-button gl-p-0!"
icon="close" icon="close"
@click="toggleDropdownContents" @click="toggleDropdownContents"
/> />
...@@ -165,17 +168,21 @@ export default { ...@@ -165,17 +168,21 @@ export default {
</li> </li>
</smart-virtual-list> </smart-virtual-list>
</div> </div>
<div v-if="isDropdownVariantSidebar" class="dropdown-footer"> <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-if="allowLabelCreate"> <li v-if="allowLabelCreate">
<gl-link <gl-link
class="d-flex w-100 flex-row text-break-word label-item" class="gl-display-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView" @click="toggleDropdownContentsCreateView"
>{{ footerCreateLabelTitle }}</gl-link
> >
{{ footerCreateLabelTitle }}
</gl-link>
</li> </li>
<li> <li>
<gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> <gl-link
:href="labelsManagePath"
class="gl-display-flex flex-row text-break-word label-item"
>
{{ footerManageLabelTitle }} {{ footerManageLabelTitle }}
</gl-link> </gl-link>
</li> </li>
......
...@@ -74,6 +74,11 @@ export default { ...@@ -74,6 +74,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
dropdownButtonText: {
type: String,
required: false,
default: __('Label'),
},
labelsListTitle: { labelsListTitle: {
type: String, type: String,
required: false, required: false,
...@@ -97,7 +102,11 @@ export default { ...@@ -97,7 +102,11 @@ export default {
}, },
computed: { computed: {
...mapState(['showDropdownButton', 'showDropdownContents']), ...mapState(['showDropdownButton', 'showDropdownContents']),
...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']), ...mapGetters([
'isDropdownVariantSidebar',
'isDropdownVariantStandalone',
'isDropdownVariantEmbedded',
]),
dropdownButtonVisible() { dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true; return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
}, },
...@@ -116,6 +125,7 @@ export default { ...@@ -116,6 +125,7 @@ export default {
allowLabelCreate: this.allowLabelCreate, allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect, allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels, allowScopedLabels: this.allowScopedLabels,
dropdownButtonText: this.dropdownButtonText,
selectedLabels: this.selectedLabels, selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath, labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath, labelsManagePath: this.labelsManagePath,
...@@ -200,7 +210,10 @@ export default { ...@@ -200,7 +210,10 @@ export default {
<template> <template>
<div <div
class="labels-select-wrapper position-relative" class="labels-select-wrapper position-relative"
:class="{ 'is-standalone': isDropdownVariantStandalone }" :class="{
'is-standalone': isDropdownVariantStandalone,
'is-embedded': isDropdownVariantEmbedded,
}"
> >
<template v-if="isDropdownVariantSidebar"> <template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed <dropdown-value-collapsed
...@@ -221,7 +234,7 @@ export default { ...@@ -221,7 +234,7 @@ export default {
ref="dropdownContents" ref="dropdownContents"
/> />
</template> </template>
<template v-if="isDropdownVariantStandalone"> <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" /> <dropdown-button v-show="dropdownButtonVisible" />
<dropdown-contents <dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents" v-if="dropdownButtonVisible && showDropdownContents"
......
...@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => { ...@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => {
: state.selectedLabels; : state.selectedLabels;
if (!selectedLabels.length) { if (!selectedLabels.length) {
return __('Label'); return state.dropdownButtonText || __('Label');
} else if (selectedLabels.length > 1) { } else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title, firstLabelName: selectedLabels[0].title,
...@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria ...@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria
*/ */
export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone; export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone;
/**
* Returns boolean representing whether dropdown variant
* is `embedded`
* @param {object} state
*/
export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded;
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -6,6 +6,7 @@ export default () => ({ ...@@ -6,6 +6,7 @@ export default () => ({
labelsCreateTitle: '', labelsCreateTitle: '',
footerCreateLabelTitle: '', footerCreateLabelTitle: '',
footerManageLabelTitle: '', footerManageLabelTitle: '',
dropdownButtonText: '',
// Paths // Paths
namespace: '', namespace: '',
......
...@@ -1089,6 +1089,10 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1089,6 +1089,10 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.dropdown-label-color-preview { .dropdown-label-color-preview {
border: 1px solid $gray-100; border: 1px solid $gray-100;
border-right: 0; border-right: 0;
&[style] {
border-color: transparent;
}
} }
} }
} }
......
<script>
import {
GlButton,
GlDatepicker,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInput,
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import LabelsSelectVue from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import createEpic from '../queries/createEpic.mutation.graphql';
export default {
components: {
GlButton,
GlDatepicker,
GlForm,
GlFormCheckbox,
GlFormInput,
GlFormGroup,
MarkdownField,
LabelsSelectVue,
},
inject: [
'groupPath',
'groupEpicsPath',
'labelsFetchPath',
'labelsManagePath',
'markdownPreviewPath',
'markdownDocsPath',
],
data() {
return {
title: '',
description: '',
confidential: false,
labels: [],
startDateFixed: null,
dueDateFixed: null,
loading: false,
};
},
computed: {
labelIds() {
return this.labels.map(label => label.id);
},
},
i18n: {
confidentialityLabel: s__(`
Epics|This epic and any containing child epics are confidential
and should only be visible to team members with at least Reporter access.
`),
epicDatesHint: s__('Epics|Leave empty to inherit from milestone dates'),
},
methods: {
save() {
this.loading = true;
return this.$apollo
.mutate({
mutation: createEpic,
variables: {
input: {
addLabelIds: this.labelIds,
groupPath: this.groupPath,
title: this.title,
description: this.description,
confidential: this.confidential,
startDateFixed: this.startDateFixed,
startDateIsFixed: Boolean(this.startDateFixed),
dueDateFixed: this.dueDateFixed,
dueDateIsFixed: Boolean(this.dueDateFixed),
},
},
})
.then(({ data }) => {
const { errors, epic } = data.createEpic;
if (errors?.length > 0) {
createFlash(errors[0]);
this.loading = false;
return;
}
visitUrl(epic.webUrl);
})
.catch(() => {
this.loading = false;
createFlash(s__('Epics|Unable to save epic. Please try again'));
});
},
updateDueDate(val) {
this.dueDateFixed = val;
},
updateStartDate(val) {
this.startDateFixed = val;
},
handleUpdateSelectedLabels(labels) {
const ids = [];
const allLabels = [...labels, ...this.labels];
this.labels = allLabels.filter(label => {
const exists = ids.includes(label.id);
ids.push(label.id);
return !exists && label.set;
});
},
},
};
</script>
<template>
<div>
<h3 class="page-title gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-5 gl-mb-6">
{{ __('New Epic') }}
</h3>
<gl-form class="common-note-form" @submit="save">
<gl-form-group :label="__('Title')" label-for="epic-title">
<gl-form-input
id="epic-title"
v-model="title"
data-testid="epic-title"
:placeholder="s__('Epics|Enter a title for your epic')"
autocomplete="off"
autofocus
/>
</gl-form-group>
<gl-form-group :label="__('Description')" label-for="epic-description">
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:can-suggest="false"
:can-attach-file="true"
:enable-autocomplete="true"
:add-spacing-classes="false"
:textarea-value="description"
:label="__('Description')"
class="md-area"
>
<template #textarea>
<textarea
id="epic-description"
v-model="description"
data-testid="epic-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="true"
:placeholder="__('Write a comment or drag your files here…')"
:aria-label="__('Description')"
>
</textarea>
</template>
</markdown-field>
</gl-form-group>
<gl-form-group :label="__('Confidentiality')" label-for="epic-confidentiality">
<gl-form-checkbox
id="epic-confidentiality"
v-model="confidential"
data-testid="epic-confidentiality"
>
{{ $options.i18n.confidentialityLabel }}
</gl-form-checkbox>
</gl-form-group>
<hr />
<gl-form-group :label="__('Labels')">
<labels-select-vue
:allow-label-edit="false"
:allow-label-create="true"
:allow-multiselect="true"
:allow-scoped-labels="false"
:selected-labels="labels"
:labels-fetch-path="labelsFetchPath"
:labels-manage-path="labelsManagePath"
:labels-filter-base-path="groupEpicsPath"
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
variant="embedded"
class="block labels js-labels-block"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select-vue>
</gl-form-group>
<gl-form-group :label="__('Start date')" :description="$options.i18n.epicDatesHint">
<div class="gl-display-inline-block gl-mr-2">
<gl-datepicker v-model="startDateFixed" data-testid="epic-start-date" />
</div>
<gl-button
v-show="startDateFixed"
variant="link"
class="gl-white-space-nowrap"
data-testid="clear-start-date"
@click="updateStartDate(null)"
>
{{ __('Clear start date') }}
</gl-button>
</gl-form-group>
<gl-form-group
class="gl-pb-4"
:label="__('Due date')"
:description="$options.i18n.epicDatesHint"
>
<div class="gl-display-inline-block gl-mr-2">
<gl-datepicker v-model="dueDateFixed" data-testid="epic-due-date" />
</div>
<gl-button
v-show="dueDateFixed"
variant="link"
class="gl-white-space-nowrap"
data-testid="clear-due-date"
@click="updateDueDate(null)"
>
{{ __('Clear due date') }}
</gl-button>
</gl-form-group>
<div class="footer-block row-content-block gl-display-flex">
<gl-button
type="submit"
variant="success"
:loading="loading"
:disabled="!title"
data-testid="save-epic"
>
{{ __('Create epic') }}
</gl-button>
<gl-button
type="button"
class="gl-ml-auto"
data-testid="cancel-epic"
:href="groupEpicsPath"
>
{{ __('Cancel') }}
</gl-button>
</div>
</gl-form>
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import EpicForm from './components/epic_form.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export function initEpicForm() {
const el = document.querySelector('.js-epic-new');
if (!el) {
return null;
}
const {
groupPath,
groupEpicsPath,
labelsFetchPath,
labelsManagePath,
markdownDocsPath,
markdownPreviewPath,
} = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
groupPath,
groupEpicsPath,
labelsFetchPath,
labelsManagePath,
markdownDocsPath,
markdownPreviewPath,
},
render(createElement) {
return createElement(EpicForm);
},
});
}
export default {};
mutation createEpic($input: CreateEpicInput!) {
createEpic(input: $input) {
epic {
webUrl
}
errors
}
}
import { initEpicForm } from 'ee/epic/new_epic_bundle';
document.addEventListener('DOMContentLoaded', () => {
initEpicForm();
});
...@@ -81,3 +81,71 @@ ...@@ -81,3 +81,71 @@
.tooltip .tooltip-inner .milestone-date-range { .tooltip .tooltip-inner .milestone-date-range {
color: $gl-text-color-tertiary; color: $gl-text-color-tertiary;
} }
.md-area.gfm-form {
@include gl-rounded-base;
@include gl-border-none;
@include gl-inset-border-1-gray-400;
&.is-focused {
@include gl-focus($gl-border-size-1, $gray-900);
@include gl-text-gray-900;
}
.markdown-area::placeholder {
@include gl-text-gray-400;
}
}
.labels-select-wrapper.is-embedded {
width: $gl-dropdown-width;
.labels-select-dropdown-button {
@include gl-bg-white;
@include gl-font-regular;
@include gl-font-base;
@include gl-line-height-normal;
@include gl-py-3;
@include gl-px-4;
@include gl-h-auto;
@include gl-text-left;
@include gl-border-none;
@include gl-inset-border-1-gray-400;
@include gl-rounded-base;
@include gl-white-space-nowrap;
.gl-button-text {
@include gl-text-gray-700;
@include gl-display-flex;
@include gl-justify-content-space-between;
@include gl-w-full;
}
.gl-icon {
@include gl-m-0;
}
}
.labels-select-dropdown-contents {
@include gl-left-0;
@include gl-shadow-x0-y2-b4-s0;
bottom: 100%;
width: 300px !important;
max-height: none;
margin-bottom: $gl-spacing-scale-6 !important;
a:not(.btn) {
@include gl-reset-color;
}
}
.dropdown-title {
padding-top: $gl-spacing-scale-2 !important;
padding-bottom: $gl-spacing-scale-4 !important;
}
.dropdown-footer .list-unstyled {
@include gl-m-0;
}
}
...@@ -10,10 +10,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -10,10 +10,10 @@ class Groups::EpicsController < Groups::ApplicationController
include DescriptionDiffActions include DescriptionDiffActions
before_action :check_epics_available! before_action :check_epics_available!
before_action :epic, except: [:index, :create, :bulk_update] before_action :epic, except: [:index, :create, :new, :bulk_update]
before_action :set_issuables_index, only: :index before_action :set_issuables_index, only: :index
before_action :authorize_update_issuable!, only: :update before_action :authorize_update_issuable!, only: :update
before_action :authorize_create_epic!, only: [:create] before_action :authorize_create_epic!, only: [:create, :new]
before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update] before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update]
before_action do before_action do
...@@ -21,6 +21,8 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -21,6 +21,8 @@ class Groups::EpicsController < Groups::ApplicationController
push_frontend_feature_flag(:confidential_epics, @group, default_enabled: true) push_frontend_feature_flag(:confidential_epics, @group, default_enabled: true)
end end
def new; end
def index def index
@epics = @issuables @epics = @issuables
......
...@@ -5,6 +5,17 @@ module EpicsHelper ...@@ -5,6 +5,17 @@ module EpicsHelper
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: issuable_initial_data(epic))
end end
def epic_new_app_data(group)
{
group_path: group.full_path,
group_epics_path: group_epics_path(group),
labels_fetch_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
labels_manage_path: group_labels_path(group),
markdown_preview_path: preview_markdown_path(group),
markdown_docs_path: help_page_path('user/markdown')
}
end
def epic_endpoint_query_params(opts) def epic_endpoint_query_params(opts)
opts[:data] ||= {} opts[:data] ||= {}
opts[:data][:endpoint_query_params] = { opts[:data][:endpoint_query_params] = {
......
- add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title _("New")
- page_title _("New epic")
.js-epic-new{ data: epic_new_app_data(@group) }
---
title: Add new epic creation page
merge_request: 32701
author:
type: added
...@@ -27,6 +27,12 @@ RSpec.describe Groups::EpicsController do ...@@ -27,6 +27,12 @@ RSpec.describe Groups::EpicsController do
it_behaves_like '404 status' it_behaves_like '404 status'
end end
describe 'GET #new' do
subject { get :new, params: { group_id: group } }
it_behaves_like '404 status'
end
describe 'GET #show' do describe 'GET #show' do
subject { get :show, params: { group_id: group, id: epic.to_param } } subject { get :show, params: { group_id: group, id: epic.to_param } }
...@@ -248,6 +254,23 @@ RSpec.describe Groups::EpicsController do ...@@ -248,6 +254,23 @@ RSpec.describe Groups::EpicsController do
end end
end end
describe 'GET #new' do
it 'renders template' do
group.add_developer(user)
get :new, params: { group_id: group }
expect(response).to render_template 'groups/epics/new'
end
context 'with unauthorized user' do
it 'returns a not found 404 response' do
get :new, params: { group_id: group }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET #show' do describe 'GET #show' do
def show_epic(format = :html) def show_epic(format = :html)
get :show, params: { group_id: group, id: epic.to_param }, format: format get :show, params: { group_id: group, id: epic.to_param }, format: format
......
import { shallowMount } from '@vue/test-utils';
import { GlForm } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import EpicForm from 'ee/epic/components/epic_form.vue';
import LabelsSelectVue from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import createEpic from 'ee/epic/queries/createEpic.mutation.graphql';
import { TEST_HOST } from 'helpers/test_constants';
jest.mock('~/lib/utils/url_utility');
const TEST_GROUP_PATH = 'gitlab-org';
const TEST_NEW_EPIC = { data: { createEpic: { epic: { webUrl: TEST_HOST } } } };
const TEST_FAILED = { data: { createEpic: { errors: ['mutation failed'] } } };
describe('ee/epic/components/epic_form.vue', () => {
let wrapper;
const createWrapper = ({ mutationResult = TEST_NEW_EPIC } = {}) => {
wrapper = shallowMount(EpicForm, {
provide: {
groupPath: TEST_GROUP_PATH,
groupEpicsPath: TEST_HOST,
labelsFetchPath: TEST_HOST,
labelsManagePath: TEST_HOST,
markdownPreviewPath: TEST_HOST,
markdownDocsPath: TEST_HOST,
},
stubs: {
ApolloMutation,
MarkdownField: '<div><slot name="textarea"></slot></div>',
},
mocks: {
$apollo: {
mutate: jest.fn().mockResolvedValue(mutationResult),
},
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLabels = () => wrapper.find(LabelsSelectVue);
const findTitle = () => wrapper.find('[data-testid="epic-title"]');
const findDescription = () => wrapper.find('[data-testid="epic-description"]');
const findConfidentialityCheck = () => wrapper.find('[data-testid="epic-confidentiality"]');
const findStartDate = () => wrapper.find('[data-testid="epic-start-date"]');
const findStartDateReset = () => wrapper.find('[data-testid="clear-start-date"]');
const findDueDate = () => wrapper.find('[data-testid="epic-due-date"]');
const findDueDateReset = () => wrapper.find('[data-testid="clear-due-date"]');
const findSaveButton = () => wrapper.find('[data-testid="save-epic"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-epic"]');
describe('when mounted', () => {
beforeEach(() => {
createWrapper();
});
it('should render the form', () => {
expect(wrapper.find(GlForm).exists()).toBe(true);
});
it('can be canceled', () => {
expect(findCancelButton().attributes('href')).toBe(TEST_HOST);
});
it('disables submit button if no title is provided', () => {
expect(findSaveButton().attributes('disabled')).toBeTruthy();
});
it.each`
field | findInput | findResetter
${'startDateFixed'} | ${findStartDate} | ${findStartDateReset}
${'dueDateFixed'} | ${findDueDate} | ${findDueDateReset}
`('can clear $field with side control', ({ field, findInput, findResetter }) => {
findInput().vm.$emit('input', new Date());
expect(wrapper.vm[field]).toBeTruthy();
findResetter().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm[field]).toBe(null);
});
});
});
describe('save', () => {
it('submits successfully if form data is provided', async () => {
createWrapper();
const addLabelIds = [1];
const title = 'Status page MVP';
const description = '### Goal\n\n- [ ] Item';
const confidential = true;
const startDateFixed = new Date();
const startDateIsFixed = true;
const dueDateFixed = null;
const dueDateIsFixed = false;
findTitle().vm.$emit('input', title);
findDescription().setValue(description);
findConfidentialityCheck().vm.$emit('input', confidential);
findLabels().vm.$emit('updateSelectedLabels', [{ id: 1, set: 1 }]);
findStartDate().vm.$emit('input', startDateFixed);
findDueDate().vm.$emit('input', dueDateFixed);
wrapper.vm.save();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createEpic,
variables: {
input: {
groupPath: TEST_GROUP_PATH,
addLabelIds,
title,
description,
confidential,
startDateFixed,
startDateIsFixed,
dueDateFixed,
dueDateIsFixed,
},
},
});
});
it.each`
status | result | loading
${'succeeds'} | ${TEST_NEW_EPIC} | ${true}
${'fails'} | ${TEST_FAILED} | ${false}
`('resets loading indicator when $status', ({ result, loading }) => {
createWrapper({ mutationResult: result });
const savePromise = wrapper.vm.save();
expect(wrapper.vm.loading).toBe(true);
return savePromise.then(() => {
expect(findSaveButton().props('loading')).toBe(loading);
});
});
});
});
...@@ -5,6 +5,23 @@ require 'spec_helper' ...@@ -5,6 +5,23 @@ require 'spec_helper'
RSpec.describe EpicsHelper, type: :helper do RSpec.describe EpicsHelper, type: :helper do
include ApplicationHelper include ApplicationHelper
describe '#epic_new_app_data' do
let(:group) { create(:group) }
it 'returns the correct data for a new epic' do
expected_data = {
group_path: group.full_path,
group_epics_path: "/groups/#{group.full_path}/-/epics",
labels_fetch_path: "/groups/#{group.full_path}/-/labels.json?include_ancestor_groups=true&only_group_labels=true",
labels_manage_path: "/groups/#{group.full_path}/-/labels",
markdown_preview_path: "/groups/#{group.full_path}/preview_markdown",
markdown_docs_path: help_page_path('user/markdown')
}
expect(helper.epic_new_app_data(group)).to match(hash_including(expected_data))
end
end
describe '#epic_endpoint_query_params' do describe '#epic_endpoint_query_params' do
let(:endpoint_data) do let(:endpoint_data) do
{ {
......
...@@ -4598,6 +4598,9 @@ msgstr "" ...@@ -4598,6 +4598,9 @@ msgstr ""
msgid "Choose file…" msgid "Choose file…"
msgstr "" msgstr ""
msgid "Choose labels"
msgstr ""
msgid "Choose the top-level group for your repository imports." msgid "Choose the top-level group for your repository imports."
msgstr "" msgstr ""
...@@ -9222,9 +9225,15 @@ msgstr "" ...@@ -9222,9 +9225,15 @@ msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?" msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr "" msgstr ""
msgid "Epics|Enter a title for your epic"
msgstr ""
msgid "Epics|How can I solve this?" msgid "Epics|How can I solve this?"
msgstr "" msgstr ""
msgid "Epics|Leave empty to inherit from milestone dates"
msgstr ""
msgid "Epics|More information" msgid "Epics|More information"
msgstr "" msgstr ""
...@@ -9264,12 +9273,18 @@ msgstr "" ...@@ -9264,12 +9273,18 @@ msgstr ""
msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely." msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely."
msgstr "" msgstr ""
msgid "Epics|This epic and any containing child epics are confidential and should only be visible to team members with at least Reporter access."
msgstr ""
msgid "Epics|This will also remove any descendents of %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}. Are you sure?" msgid "Epics|This will also remove any descendents of %{bStart}%{targetEpicTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}. Are you sure?"
msgstr "" msgstr ""
msgid "Epics|To schedule your epic's %{epicDateType} date based on milestones, assign a milestone with a %{epicDateType} date to any issue in the epic." msgid "Epics|To schedule your epic's %{epicDateType} date based on milestones, assign a milestone with a %{epicDateType} date to any issue in the epic."
msgstr "" msgstr ""
msgid "Epics|Unable to save epic. Please try again"
msgstr ""
msgid "Epics|due" msgid "Epics|due"
msgstr "" msgstr ""
...@@ -15416,6 +15431,9 @@ msgstr "" ...@@ -15416,6 +15431,9 @@ msgstr ""
msgid "New Environment" msgid "New Environment"
msgstr "" msgstr ""
msgid "New Epic"
msgstr ""
msgid "New File" msgid "New File"
msgstr "" msgstr ""
...@@ -20960,6 +20978,9 @@ msgstr "" ...@@ -20960,6 +20978,9 @@ msgstr ""
msgid "Select health status" msgid "Select health status"
msgstr "" msgstr ""
msgid "Select label"
msgstr ""
msgid "Select labels" msgid "Select labels"
msgstr "" msgstr ""
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui'; import { GlIcon, GlButton } from '@gitlab/ui';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data'; import { mockConfig } from './mock_data';
let store;
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => { const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule()); store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState); store.dispatch('setInitialState', initialState);
...@@ -33,26 +34,32 @@ describe('DropdownButton', () => { ...@@ -33,26 +34,32 @@ describe('DropdownButton', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const findDropdownButton = () => wrapper.find(GlButton);
const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
const findDropdownIcon = () => wrapper.find(GlIcon);
describe('methods', () => { describe('methods', () => {
describe('handleButtonClick', () => { describe('handleButtonClick', () => {
it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => { it.each`
const event = { variant
stopPropagation: jest.fn(), ${'standalone'}
}; ${'embedded'}
wrapper = createComponent({ `(
...mockConfig, 'toggles dropdown content and stops event propagation when `state.variant` is "$variant"',
variant: 'standalone', ({ variant }) => {
}); const event = { stopPropagation: jest.fn() };
jest.spyOn(wrapper.vm, 'toggleDropdownContents'); wrapper = createComponent({
...mockConfig,
wrapper.vm.handleButtonClick(event); variant,
});
expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled(); findDropdownButton().vm.$emit('click', event);
wrapper.destroy(); expect(store.state.showDropdownContents).toBe(true);
}); expect(event.stopPropagation).toHaveBeenCalled();
},
);
}); });
}); });
...@@ -61,15 +68,24 @@ describe('DropdownButton', () => { ...@@ -61,15 +68,24 @@ describe('DropdownButton', () => {
expect(wrapper.is('gl-button-stub')).toBe(true); expect(wrapper.is('gl-button-stub')).toBe(true);
}); });
it('renders button text element', () => { it('renders default button text element', () => {
const dropdownTextEl = wrapper.find('.dropdown-toggle-text'); const dropdownTextEl = findDropdownText();
expect(dropdownTextEl.exists()).toBe(true); expect(dropdownTextEl.exists()).toBe(true);
expect(dropdownTextEl.text()).toBe('Label'); expect(dropdownTextEl.text()).toBe('Label');
}); });
it('renders provided button text element', () => {
store.state.dropdownButtonText = 'Custom label';
const dropdownTextEl = findDropdownText();
return wrapper.vm.$nextTick().then(() => {
expect(dropdownTextEl.text()).toBe('Custom label');
});
});
it('renders chevron icon element', () => { it('renders chevron icon element', () => {
const iconEl = wrapper.find(GlIcon); const iconEl = findDropdownIcon();
expect(iconEl.exists()).toBe(true); expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('chevron-down'); expect(iconEl.props('name')).toBe('chevron-down');
......
...@@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => { ...@@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => {
describe('DropdownContentsLabelsView', () => { describe('DropdownContentsLabelsView', () => {
let wrapper; let wrapper;
let wrapperStandalone; let wrapperStandalone;
let wrapperEmbedded;
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => { ...@@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => {
...mockConfig, ...mockConfig,
variant: 'standalone', variant: 'standalone',
}); });
wrapperEmbedded = createComponent({
...mockConfig,
variant: 'embedded',
});
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapperStandalone.destroy(); wrapperStandalone.destroy();
wrapperEmbedded.destroy();
}); });
describe('computed', () => { describe('computed', () => {
...@@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => { ...@@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false);
}); });
it('renders dropdown title element when `state.variant` is "embedded"', () => {
expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true);
});
it('renders dropdown close button element', () => { it('renders dropdown close button element', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
...@@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => { ...@@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => {
it('does not render footer list items when `state.variant` is "standalone"', () => { it('does not render footer list items when `state.variant` is "standalone"', () => {
expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false);
}); });
it('renders footer list items when `state.variant` is "embedded"', () => {
expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true);
});
}); });
}); });
...@@ -89,18 +89,23 @@ describe('LabelsSelectRoot', () => { ...@@ -89,18 +89,23 @@ describe('LabelsSelectRoot', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
}); });
it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => { it.each`
const wrapperStandalone = createComponent({ variant | cssClass
...mockConfig, ${'standalone'} | ${'is-standalone'}
variant: 'standalone', ${'embedded'} | ${'is-embedded'}
}); `(
'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
return wrapperStandalone.vm.$nextTick(() => { ({ variant, cssClass }) => {
expect(wrapperStandalone.classes()).toContain('is-standalone'); wrapper = createComponent({
...mockConfig,
wrapperStandalone.destroy(); variant,
}); });
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.classes()).toContain(cssClass);
});
},
);
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
......
...@@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor ...@@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor
describe('LabelsSelect Getters', () => { describe('LabelsSelect Getters', () => {
describe('dropdownButtonText', () => { describe('dropdownButtonText', () => {
it('returns string "Label" when state.labels has no selected labels', () => { it.each`
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; labelType | dropdownButtonText | expected
${'default'} | ${''} | ${'Label'}
expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( ${'custom'} | ${'Custom label'} | ${'Custom label'}
'Label', `(
); 'returns $labelType text when state.labels has no selected labels',
}); ({ dropdownButtonText, expected }) => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const selectedLabels = [];
const state = { labels, selectedLabels, dropdownButtonText };
expect(getters.dropdownButtonText(state, {})).toBe(expected);
},
);
it('returns label title when state.labels has only 1 label', () => { it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }]; const labels = [{ id: 1, title: 'Foobar', set: 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