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 = {
Sidebar: 'sidebar',
Standalone: 'standalone',
Embedded: 'embedded',
};
export const LIST_BUFFER_SIZE = 5;
......@@ -8,12 +8,16 @@ export default {
GlIcon,
},
computed: {
...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
...mapGetters([
'dropdownButtonText',
'isDropdownVariantStandalone',
'isDropdownVariantEmbedded',
]),
},
methods: {
...mapActions(['toggleDropdownContents']),
handleButtonClick(e) {
if (this.isDropdownVariantStandalone) {
if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
e.stopPropagation();
}
......
......@@ -88,12 +88,16 @@ export default {
@click.prevent="handleColorClick(color)"
/>
</div>
<div class="color-input-container d-flex">
<div class="color-input-container gl-display-flex">
<span
class="dropdown-label-color-preview position-relative position-relative d-inline-block"
:style="{ backgroundColor: selectedColor }"
></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 class="dropdown-actions clearfix pt-2 px-2">
......
......@@ -36,7 +36,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
......@@ -126,16 +126,19 @@ export default {
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<gl-loading-icon
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"
/>
<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>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button p-0"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
......@@ -165,17 +168,21 @@ export default {
</li>
</smart-virtual-list>
</div>
<div v-if="isDropdownVariantSidebar" class="dropdown-footer">
<div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<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"
>{{ footerCreateLabelTitle }}</gl-link
>
{{ footerCreateLabelTitle }}
</gl-link>
</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 }}
</gl-link>
</li>
......
......@@ -74,6 +74,11 @@ export default {
required: false,
default: '',
},
dropdownButtonText: {
type: String,
required: false,
default: __('Label'),
},
labelsListTitle: {
type: String,
required: false,
......@@ -97,7 +102,11 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
...mapGetters([
'isDropdownVariantSidebar',
'isDropdownVariantStandalone',
'isDropdownVariantEmbedded',
]),
dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
},
......@@ -116,6 +125,7 @@ export default {
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
dropdownButtonText: this.dropdownButtonText,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
......@@ -200,7 +210,10 @@ export default {
<template>
<div
class="labels-select-wrapper position-relative"
:class="{ 'is-standalone': isDropdownVariantStandalone }"
:class="{
'is-standalone': isDropdownVariantStandalone,
'is-embedded': isDropdownVariantEmbedded,
}"
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
......@@ -221,7 +234,7 @@ export default {
ref="dropdownContents"
/>
</template>
<template v-if="isDropdownVariantStandalone">
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
......
......@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => {
: state.selectedLabels;
if (!selectedLabels.length) {
return __('Label');
return state.dropdownButtonText || __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
......@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria
*/
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
export default () => {};
......@@ -6,6 +6,7 @@ export default () => ({
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
dropdownButtonText: '',
// Paths
namespace: '',
......
......@@ -1089,6 +1089,10 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.dropdown-label-color-preview {
border: 1px solid $gray-100;
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 @@
.tooltip .tooltip-inner .milestone-date-range {
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
include DescriptionDiffActions
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 :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 do
......@@ -21,6 +21,8 @@ class Groups::EpicsController < Groups::ApplicationController
push_frontend_feature_flag(:confidential_epics, @group, default_enabled: true)
end
def new; end
def index
@epics = @issuables
......
......@@ -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))
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)
opts[:data] ||= {}
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
it_behaves_like '404 status'
end
describe 'GET #new' do
subject { get :new, params: { group_id: group } }
it_behaves_like '404 status'
end
describe 'GET #show' do
subject { get :show, params: { group_id: group, id: epic.to_param } }
......@@ -248,6 +254,23 @@ RSpec.describe Groups::EpicsController do
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
def show_epic(format = :html)
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'
RSpec.describe EpicsHelper, type: :helper do
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
let(:endpoint_data) do
{
......
......@@ -4598,6 +4598,9 @@ msgstr ""
msgid "Choose file…"
msgstr ""
msgid "Choose labels"
msgstr ""
msgid "Choose the top-level group for your repository imports."
msgstr ""
......@@ -9222,9 +9225,15 @@ msgstr ""
msgid "Epics|Are you sure you want to remove %{bStart}%{targetIssueTitle}%{bEnd} from %{bStart}%{parentEpicTitle}%{bEnd}?"
msgstr ""
msgid "Epics|Enter a title for your epic"
msgstr ""
msgid "Epics|How can I solve this?"
msgstr ""
msgid "Epics|Leave empty to inherit from milestone dates"
msgstr ""
msgid "Epics|More information"
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."
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?"
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."
msgstr ""
msgid "Epics|Unable to save epic. Please try again"
msgstr ""
msgid "Epics|due"
msgstr ""
......@@ -15416,6 +15431,9 @@ msgstr ""
msgid "New Environment"
msgstr ""
msgid "New Epic"
msgstr ""
msgid "New File"
msgstr ""
......@@ -20960,6 +20978,9 @@ msgstr ""
msgid "Select health status"
msgstr ""
msgid "Select label"
msgstr ""
msgid "Select labels"
msgstr ""
......
import Vuex from 'vuex';
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 labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
let store;
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
......@@ -33,26 +34,32 @@ describe('DropdownButton', () => {
wrapper.destroy();
});
const findDropdownButton = () => wrapper.find(GlButton);
const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
const findDropdownIcon = () => wrapper.find(GlIcon);
describe('methods', () => {
describe('handleButtonClick', () => {
it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => {
const event = {
stopPropagation: jest.fn(),
};
wrapper = createComponent({
...mockConfig,
variant: 'standalone',
});
jest.spyOn(wrapper.vm, 'toggleDropdownContents');
wrapper.vm.handleButtonClick(event);
expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
wrapper.destroy();
});
it.each`
variant
${'standalone'}
${'embedded'}
`(
'toggles dropdown content and stops event propagation when `state.variant` is "$variant"',
({ variant }) => {
const event = { stopPropagation: jest.fn() };
wrapper = createComponent({
...mockConfig,
variant,
});
findDropdownButton().vm.$emit('click', event);
expect(store.state.showDropdownContents).toBe(true);
expect(event.stopPropagation).toHaveBeenCalled();
},
);
});
});
......@@ -61,15 +68,24 @@ describe('DropdownButton', () => {
expect(wrapper.is('gl-button-stub')).toBe(true);
});
it('renders button text element', () => {
const dropdownTextEl = wrapper.find('.dropdown-toggle-text');
it('renders default button text element', () => {
const dropdownTextEl = findDropdownText();
expect(dropdownTextEl.exists()).toBe(true);
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', () => {
const iconEl = wrapper.find(GlIcon);
const iconEl = findDropdownIcon();
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('chevron-down');
......
......@@ -44,6 +44,7 @@ const createComponent = (initialState = mockConfig) => {
describe('DropdownContentsLabelsView', () => {
let wrapper;
let wrapperStandalone;
let wrapperEmbedded;
beforeEach(() => {
wrapper = createComponent();
......@@ -51,11 +52,16 @@ describe('DropdownContentsLabelsView', () => {
...mockConfig,
variant: 'standalone',
});
wrapperEmbedded = createComponent({
...mockConfig,
variant: 'embedded',
});
});
afterEach(() => {
wrapper.destroy();
wrapperStandalone.destroy();
wrapperEmbedded.destroy();
});
describe('computed', () => {
......@@ -211,6 +217,10 @@ describe('DropdownContentsLabelsView', () => {
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', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
......@@ -291,5 +301,9 @@ describe('DropdownContentsLabelsView', () => {
it('does not render footer list items when `state.variant` is "standalone"', () => {
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', () => {
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"', () => {
const wrapperStandalone = createComponent({
...mockConfig,
variant: 'standalone',
});
return wrapperStandalone.vm.$nextTick(() => {
expect(wrapperStandalone.classes()).toContain('is-standalone');
wrapperStandalone.destroy();
});
});
it.each`
variant | cssClass
${'standalone'} | ${'is-standalone'}
${'embedded'} | ${'is-embedded'}
`(
'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
({ variant, cssClass }) => {
wrapper = createComponent({
...mockConfig,
variant,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.classes()).toContain(cssClass);
});
},
);
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
......
......@@ -2,13 +2,20 @@ import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/stor
describe('LabelsSelect Getters', () => {
describe('dropdownButtonText', () => {
it('returns string "Label" when state.labels has no selected labels', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
'Label',
);
});
it.each`
labelType | dropdownButtonText | expected
${'default'} | ${''} | ${'Label'}
${'custom'} | ${'Custom label'} | ${'Custom 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', () => {
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