Commit 004b6e12 authored by Kushal Pandya's avatar Kushal Pandya

Vue Labels Select Dropdown

Adds pure Vue implementation of Labels Select
dropdown to use with Issuable sidebars.
parent 859681c6
......@@ -27,7 +27,12 @@ export default {
<span :style="labelStyle" class="badge color-label">
{{ label.title }}
</span>
<gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport">
<gl-tooltip
v-if="label.description"
:target="() => $refs.regularLabelRef"
placement="top"
boundary="viewport"
>
{{ label.description }}
</gl-tooltip>
</a>
......
......@@ -33,7 +33,12 @@ export default {
<span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
{{ label.title }}
</span>
<gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
<gl-tooltip
v-if="label.description"
:target="() => $refs.labelTitleRef"
placement="top"
boundary="viewport"
>
<span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
><br />
{{ label.description }}
......
<script>
import { mapGetters } from 'vuex';
import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
},
computed: {
...mapGetters(['dropdownButtonText']),
},
};
</script>
<template>
<gl-button class="labels-select-dropdown-button w-100 text-left">
<span class="dropdown-toggle-text">{{ dropdownButtonText }}</span>
<gl-icon name="chevron-down" class="pull-right" />
</gl-button>
</template>
<script>
import { mapState } from 'vuex';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
},
computed: {
...mapState(['showDropdownContentsCreateView']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
}
return 'dropdown-contents-labels-view';
},
},
};
</script>
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
>
<component :is="dropdownContentsView" />
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import {
GlTooltipDirective,
GlButton,
GlIcon,
GlFormInput,
GlLink,
GlLoadingIcon,
} from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
GlFormInput,
GlLink,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
data() {
return {
labelTitle: '',
selectedColor: '',
};
},
computed: {
...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
disableCreate() {
return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
},
suggestedColors() {
const colorsMap = gon.suggested_label_colors;
return Object.keys(colorsMap).map(color => ({ [color]: colorsMap[color] }));
},
},
methods: {
...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
getColorCode(color) {
return Object.keys(color).pop();
},
getColorName(color) {
return Object.values(color).pop();
},
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
handleCreateClick() {
this.createLabel({
title: this.labelTitle,
color: this.selectedColor,
});
},
},
};
</script>
<template>
<div class="labels-select-contents-create">
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
<gl-button
:aria-label="__('Go back')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContentsCreateView"
>
<gl-icon name="arrow-left" />
</gl-button>
<span class="flex-grow-1">{{ labelsCreateTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContents"
>
<gl-icon name="close" />
</gl-button>
</div>
<div class="dropdown-input">
<gl-form-input
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
/>
</div>
<div class="dropdown-content px-2">
<div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
v-gl-tooltip:tooltipcontainer
:style="{ backgroundColor: getColorCode(color) }"
:title="getColorName(color)"
@click.prevent="handleColorClick(color)"
/>
</div>
<div class="color-input-container d-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')" />
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
<gl-button
:disabled="disableCreate"
variant="primary"
class="pull-left d-flex align-items-center"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
<gl-button class="pull-right" @click="toggleDropdownContentsCreateView">
{{ __('Cancel') }}
</gl-button>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
export default {
components: {
GlLoadingIcon,
GlButton,
GlIcon,
GlSearchBoxByType,
GlLink,
},
data() {
return {
searchKey: '',
currentHighlightItem: -1,
};
},
computed: {
...mapState([
'labelsManagePath',
'labels',
'labelsFetchInProgress',
'labelsListTitle',
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
...mapGetters(['selectedLabelsList']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
label.title.toLowerCase().includes(this.searchKey.toLowerCase()),
);
}
return this.labels;
},
},
watch: {
searchKey(value) {
// When there is search string present
// and there are matching results,
// highlight first item by default.
if (value && this.visibleLabels.length) {
this.currentHighlightItem = 0;
}
},
},
mounted() {
this.fetchLabels();
},
methods: {
...mapActions([
'toggleDropdownContents',
'toggleDropdownContentsCreateView',
'fetchLabels',
'updateSelectedLabels',
]),
getDropdownLabelBoxStyle(label) {
return {
backgroundColor: label.color,
};
},
isLabelSelected(label) {
return this.selectedLabelsList.includes(label.id);
},
/**
* This method scrolls item from dropdown into
* the view if it is off the viewable area of the
* container.
*/
scrollIntoViewIfNeeded() {
const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
if (highlightedLabel) {
const rect = highlightedLabel.getBoundingClientRect();
if (rect.bottom > this.$refs.labelsListContainer.clientHeight) {
highlightedLabel.scrollIntoView(false);
}
if (rect.top < 0) {
highlightedLabel.scrollIntoView();
}
}
},
/**
* This method enables keyboard navigation support for
* the dropdown.
*/
handleKeyDown(e) {
if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
this.currentHighlightItem -= 1;
} else if (
e.keyCode === DOWN_KEY_CODE &&
this.currentHighlightItem < this.visibleLabels.length - 1
) {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
}
if (e.keyCode !== ESC_KEY_CODE) {
// Scroll the list only after highlighting
// styles are rendered completely.
this.$nextTick(() => {
this.scrollIntoViewIfNeeded();
});
}
},
handleLabelClick(label) {
this.updateSelectedLabels([label]);
},
},
};
</script>
<template>
<div class="labels-select-contents-list" @keydown="handleKeyDown">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
size="md"
/>
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContents"
>
<gl-icon name="close" />
</gl-button>
</div>
<div class="dropdown-input">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
<div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
<ul class="list-unstyled mb-0">
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
<gl-link
class="d-flex align-items-baseline text-break-word label-item"
:class="{ 'is-focused': index === currentHighlightItem }"
@click="handleLabelClick(label)"
>
<gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" />
<span v-show="!label.set" class="mr-3 pr-2"></span>
<span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span>
<span>{{ label.title }}</span>
</gl-link>
</li>
<li v-if="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
</ul>
</div>
<div class="dropdown-footer">
<ul class="list-unstyled">
<li>
<gl-button
variant="link"
class="d-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
>{{ footerCreateLabelTitle }}</gl-button
>
</li>
<li>
<gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
{{ footerManageLabelTitle }}
</gl-link>
</li>
</ul>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLoadingIcon,
},
props: {
labelsSelectInProgress: {
type: Boolean,
required: true,
},
},
computed: {
...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
},
methods: {
...mapActions(['toggleDropdownContents']),
},
};
</script>
<template>
<div class="title hide-collapsed append-bottom-10">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" inline />
<gl-button
variant="link"
class="pull-right js-sidebar-dropdown-toggle"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
>{{ __('Edit') }}</gl-button
>
</template>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlLabel } from '@gitlab/ui';
import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
GlLabel,
},
computed: {
...mapState([
'selectedLabels',
'allowScopedLabels',
'labelsFilterBasePath',
'scopedLabelsDocumentationPath',
]),
},
methods: {
labelFilterUrl(label) {
return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
},
scopedLabel(label) {
return this.allowScopedLabels && isScopedLabel(label);
},
},
};
</script>
<template>
<div
:class="{
'has-labels': selectedLabels.length,
}"
class="hide-collapsed value issuable-show-labels js-value"
>
<span v-if="!selectedLabels.length" class="text-secondary">
<slot></slot>
</span>
<template v-for="label in selectedLabels" v-else>
<gl-label
:key="label.id"
:title="label.title"
:description="label.description"
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
:scoped-labels-documentation-link="scopedLabelsDocumentationPath"
tooltip-placement="top"
/>
</template>
</div>
</template>
<script>
import Vue from 'vue';
import Vuex, { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
Vue.use(Vuex);
export default {
store: new Vuex.Store(labelsSelectModule()),
components: {
DropdownTitle,
DropdownValue,
DropdownButton,
DropdownContents,
DropdownValueCollapsed,
},
props: {
allowLabelEdit: {
type: Boolean,
required: true,
},
allowLabelCreate: {
type: Boolean,
required: true,
},
allowScopedLabels: {
type: Boolean,
required: true,
},
dropdownOnly: {
type: Boolean,
required: false,
default: false,
},
selectedLabels: {
type: Array,
required: false,
default: () => [],
},
labelsSelectInProgress: {
type: Boolean,
required: false,
default: false,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
labelsManagePath: {
type: String,
required: false,
default: '',
},
labelsFilterBasePath: {
type: String,
required: false,
default: '',
},
scopedLabelsDocumentationPath: {
type: String,
required: false,
default: '',
},
labelsListTitle: {
type: String,
required: false,
default: __('Assign labels'),
},
labelsCreateTitle: {
type: String,
required: false,
default: __('Create group label'),
},
footerCreateLabelTitle: {
type: String,
required: false,
default: __('Create group label'),
},
footerManageLabelTitle: {
type: String,
required: false,
default: __('Manage group labels'),
},
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
},
watch: {
selectedLabels(selectedLabels) {
this.setInitialState({
selectedLabels,
});
},
},
mounted() {
this.setInitialState({
dropdownOnly: this.dropdownOnly,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowScopedLabels: this.allowScopedLabels,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
footerManageLabelTitle: this.footerManageLabelTitle,
});
this.$store.subscribeAction({
after: this.handleVuexActionDispatch,
});
},
methods: {
...mapActions(['setInitialState']),
/**
* This method differentiates between
* dispatched actions and calls necessary method.
*/
handleVuexActionDispatch(action, state) {
if (
action.type === 'toggleDropdownContents' &&
!state.showDropdownButton &&
!state.showDropdownContents
) {
this.handleDropdownClose(state.labels.filter(label => label.touched));
}
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
// on UI.
if (labels.length) this.$emit('updateSelectedLabels', labels);
this.$emit('onDropdownClose');
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="labels-select-wrapper position-relative">
<div v-if="!dropdownOnly">
<dropdown-value-collapsed
v-if="allowLabelCreate"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<dropdown-value v-show="!showDropdownButton">
<slot></slot>
</dropdown-value>
<dropdown-button v-show="showDropdownButton" />
<dropdown-contents v-if="showDropdownButton && showDropdownContents" />
</div>
</div>
</template>
import flash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
export const toggleDropdownContentsCreateView = ({ commit }) =>
commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
flash(__('Error fetching labels.'));
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
})
.catch(() => dispatch('receiveLabelsFailure'));
};
export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
export const receiveCreateLabelFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_LABEL_FAILURE);
flash(__('Error creating label.'));
};
export const createLabel = ({ state, dispatch }, label) => {
dispatch('requestCreateLabel');
axios
.post(state.labelsManagePath, {
label,
})
.then(({ data }) => {
if (data.id) {
dispatch('receiveCreateLabelSuccess');
dispatch('toggleDropdownContentsCreateView');
} else {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
throw new Error('Error Creating Label');
}
})
.catch(() => {
dispatch('receiveCreateLabelFailure');
});
};
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { __, s__, sprintf } from '~/locale';
/**
* Returns string representing current labels
* selection on dropdown button.
*
* @param {object} state
*/
export const dropdownButtonText = state => {
const selectedLabels = state.labels.filter(label => label.set);
if (!selectedLabels.length) {
return __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
remainingLabelCount: selectedLabels.length - 1,
});
}
return selectedLabels[0].title;
};
/**
* Returns array containing only label IDs from
* selectedLabels array.
* @param {object} state
*/
export const selectedLabelsList = state => state.selectedLabels.map(label => label.id);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
state: state(),
actions,
getters,
mutations,
});
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_LABELS = 'REQUEST_LABELS';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, props) {
Object.assign(state, { ...props });
},
[types.TOGGLE_DROPDOWN_BUTTON](state) {
state.showDropdownButton = !state.showDropdownButton;
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
if (!state.dropdownOnly) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
// Ensure that Create View is hidden by default
// when dropdown contents are revealed.
if (state.showDropdownContents) {
state.showDropdownContentsCreateView = false;
}
},
[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
},
[types.REQUEST_LABELS](state) {
state.labelsFetchInProgress = true;
},
[types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
// Iterate over every label and add a `set` prop
// to determine whether it is already a part of
// selectedLabels array.
const selectedLabelIds = state.selectedLabels.map(label => label.id);
state.labelsFetchInProgress = false;
state.labels = labels.reduce((allLabels, label) => {
allLabels.push({
...label,
set: selectedLabelIds.includes(label.id),
});
return allLabels;
}, []);
},
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = false;
},
[types.REQUEST_CREATE_LABEL](state) {
state.labelCreateInProgress = true;
},
[types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
state.labelCreateInProgress = false;
},
[types.RECEIVE_CREATE_LABEL_FAILURE](state) {
state.labelCreateInProgress = false;
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Iterate over all the labels and update
// `set` prop value to represent their current state.
const labelIds = labels.map(label => label.id);
state.labels = state.labels.reduce((allLabels, label) => {
if (labelIds.includes(label.id)) {
allLabels.push({
...label,
touched: true,
set: !label.set,
});
} else {
allLabels.push(label);
}
return allLabels;
}, []);
},
};
export default () => ({
// Initial Data
labels: [],
selectedLabels: [],
labelsListTitle: '',
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
// Paths
namespace: '',
labelsFetchPath: '',
labelsFilterBasePath: '',
scopedLabelsDocumentationPath: '#',
// UI Flags
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
dropdownOnly: false,
showDropdownButton: false,
showDropdownContents: false,
showDropdownContentsCreateView: false,
labelsFetchInProgress: false,
labelCreateInProgress: false,
selectedLabelsUpdated: false,
});
......@@ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
opacity: 0;
}
}
.labels-select-wrapper {
.labels-select-dropdown-contents {
min-height: $dropdown-min-height;
max-height: 330px;
background-color: $white-light;
border: 1px solid $border-color;
box-shadow: 0 2px 4px $dropdown-shadow-color;
z-index: 2;
.dropdown-content {
height: 135px;
}
}
.labels-fetch-loading {
top: 0;
left: 0;
opacity: 0.5;
background-color: $white-light;
z-index: 1;
}
.dropdown-header-button {
.gl-icon {
color: $dropdown-title-btn-color;
&:hover {
color: $gl-gray-400;
}
}
}
.label-item {
padding: 8px 20px;
&:hover,
&.is-focused {
@include dropdown-item-hover;
text-decoration: none;
}
}
.color-input-container {
.dropdown-label-color-preview {
border: 1px solid $gray-200;
border-right: 0;
}
}
}
......@@ -7778,6 +7778,9 @@ msgstr ""
msgid "Error creating epic"
msgstr ""
msgid "Error creating label."
msgstr ""
msgid "Error deleting %{issuableType}"
msgstr ""
......@@ -21315,6 +21318,9 @@ msgstr ""
msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr ""
msgid "Use custom color #FF0000"
msgstr ""
msgid "Use group milestones to manage issues from multiple projects in the same milestone."
msgstr ""
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlIcon } 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';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownButton, {
localVue,
store,
});
};
describe('DropdownButton', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element', () => {
expect(wrapper.is('gl-button-stub')).toBe(true);
});
it('renders button text element', () => {
const dropdownTextEl = wrapper.find('.dropdown-toggle-text');
expect(dropdownTextEl.exists()).toBe(true);
expect(dropdownTextEl.text()).toBe('Label');
});
it('renders chevron icon element', () => {
const iconEl = wrapper.find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('chevron-down');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig, mockSuggestedColors } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContentsCreateView, {
localVue,
store,
});
};
describe('DropdownContentsCreateView', () => {
let wrapper;
const colors = Object.keys(mockSuggestedColors).map(color => ({
[color]: mockSuggestedColors[color],
}));
beforeEach(() => {
gon.suggested_label_colors = mockSuggestedColors;
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('disableCreate', () => {
it('returns `true` when label title and color is not defined', () => {
expect(wrapper.vm.disableCreate).toBe(true);
});
it('returns `true` when `labelCreateInProgress` is true', () => {
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
wrapper.vm.$store.dispatch('requestCreateLabel');
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.disableCreate).toBe(true);
});
});
it('returns `false` when label title and color is defined and create request is not already in progress', () => {
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.disableCreate).toBe(false);
});
});
});
describe('suggestedColors', () => {
it('returns array of color objects containing color code and name', () => {
colors.forEach((color, index) => {
expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color));
});
});
});
});
describe('methods', () => {
describe('getColorCode', () => {
it('returns color code from color object', () => {
expect(wrapper.vm.getColorCode(colors[0])).toBe(Object.keys(colors[0]).pop());
});
});
describe('getColorName', () => {
it('returns color name from color object', () => {
expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop());
});
});
describe('handleColorClick', () => {
it('sets provided `color` param to `selectedColor` prop', () => {
wrapper.vm.handleColorClick(colors[0]);
expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop());
});
});
describe('handleCreateClick', () => {
it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', () => {
jest.spyOn(wrapper.vm, 'createLabel').mockImplementation();
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
wrapper.vm.handleCreateClick();
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.createLabel).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Foo',
color: '#ff0000',
}),
);
});
});
});
});
describe('template', () => {
it('renders component container element with class "labels-select-contents-create"', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-create');
});
it('renders dropdown back button element', () => {
const backBtnEl = wrapper
.find('.dropdown-title')
.findAll(GlButton)
.at(0);
expect(backBtnEl.exists()).toBe(true);
expect(backBtnEl.attributes('aria-label')).toBe('Go back');
expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left');
});
it('renders dropdown title element', () => {
const headerEl = wrapper.find('.dropdown-title > span');
expect(headerEl.exists()).toBe(true);
expect(headerEl.text()).toBe('Create label');
});
it('renders dropdown close button element', () => {
const closeBtnEl = wrapper
.find('.dropdown-title')
.findAll(GlButton)
.at(1);
expect(closeBtnEl.exists()).toBe(true);
expect(closeBtnEl.attributes('aria-label')).toBe('Close');
expect(closeBtnEl.find(GlIcon).props('name')).toBe('close');
});
it('renders label title input element', () => {
const titleInputEl = wrapper.find('.dropdown-input').find(GlFormInput);
expect(titleInputEl.exists()).toBe(true);
expect(titleInputEl.attributes('placeholder')).toBe('Name new label');
expect(titleInputEl.attributes('autofocus')).toBe('true');
});
it('renders color block element for all suggested colors', () => {
const colorBlocksEl = wrapper.find('.dropdown-content').findAll(GlLink);
colorBlocksEl.wrappers.forEach((colorBlock, index) => {
expect(colorBlock.attributes('style')).toContain('background-color');
expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop());
});
});
it('renders color input element', () => {
wrapper.setData({
selectedColor: '#ff0000',
});
return wrapper.vm.$nextTick(() => {
const colorPreviewEl = wrapper.find(
'.color-input-container > .dropdown-label-color-preview',
);
const colorInputEl = wrapper.find('.color-input-container').find(GlFormInput);
expect(colorPreviewEl.exists()).toBe(true);
expect(colorPreviewEl.attributes('style')).toContain('background-color');
expect(colorInputEl.exists()).toBe(true);
expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000');
expect(colorInputEl.attributes('value')).toBe('#ff0000');
});
});
it('renders create button element', () => {
const createBtnEl = wrapper
.find('.dropdown-actions')
.findAll(GlButton)
.at(0);
expect(createBtnEl.exists()).toBe(true);
expect(createBtnEl.text()).toContain('Create');
});
it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', () => {
wrapper.vm.$store.dispatch('requestCreateLabel');
return wrapper.vm.$nextTick(() => {
const loadingIconEl = wrapper.find('.dropdown-actions').find(GlLoadingIcon);
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.isVisible()).toBe(true);
});
});
it('renders cancel button element', () => {
const cancelBtnEl = wrapper
.find('.dropdown-actions')
.findAll(GlButton)
.at(1);
expect(cancelBtnEl.exists()).toBe(true);
expect(cancelBtnEl.text()).toContain('Cancel');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions';
import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters';
import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store({
getters,
mutations,
state: {
...defaultState(),
footerCreateLabelTitle: 'Create label',
footerManageLabelTitle: 'Manage labels',
},
actions: {
...actions,
fetchLabels: jest.fn(),
},
});
store.dispatch('setInitialState', initialState);
store.dispatch('receiveLabelsSuccess', mockLabels);
return shallowMount(DropdownContentsLabelsView, {
localVue,
store,
});
};
describe('DropdownContentsLabelsView', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('visibleLabels', () => {
it('returns matching labels filtered with `searchKey`', () => {
wrapper.setData({
searchKey: 'bug',
});
expect(wrapper.vm.visibleLabels.length).toBe(1);
expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
});
it('returns all labels when `searchKey` is empty', () => {
wrapper.setData({
searchKey: '',
});
expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
});
});
});
describe('methods', () => {
describe('getDropdownLabelBoxStyle', () => {
it('returns an object containing `backgroundColor` based on provided `label` param', () => {
expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual(
expect.objectContaining({
backgroundColor: mockRegularLabel.color,
}),
);
});
});
describe('isLabelSelected', () => {
it('returns true when provided `label` param is one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
});
it('returns false when provided `label` param is not one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
});
});
describe('handleKeyDown', () => {
it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: UP_KEY_CODE,
});
expect(wrapper.vm.currentHighlightItem).toBe(0);
});
it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: DOWN_KEY_CODE,
});
expect(wrapper.vm.currentHighlightItem).toBe(2);
});
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: ENTER_KEY_CODE,
});
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
{
...mockLabels[1],
set: true,
},
]);
});
it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: ESC_KEY_CODE,
});
expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
});
it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: DOWN_KEY_CODE,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
});
});
});
describe('handleLabelClick', () => {
it('calls action `updateSelectedLabels` with provided `label` param', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.vm.handleLabelClick(mockRegularLabel);
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
});
});
});
describe('template', () => {
it('renders component container element with class `labels-select-contents-list`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-list');
});
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
wrapper.vm.$store.dispatch('requestLabels');
return wrapper.vm.$nextTick(() => {
const loadingIconEl = wrapper.find(GlLoadingIcon);
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
});
});
it('renders dropdown title element', () => {
const titleEl = wrapper.find('.dropdown-title > span');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe('Assign labels');
});
it('renders dropdown close button element', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
expect(closeButtonEl.exists()).toBe(true);
expect(closeButtonEl.find(GlIcon).exists()).toBe(true);
expect(closeButtonEl.find(GlIcon).props('name')).toBe('close');
});
it('renders label search input element', () => {
const searchInputEl = wrapper.find(GlSearchBoxByType);
expect(searchInputEl.exists()).toBe(true);
expect(searchInputEl.attributes('autofocus')).toBe('true');
});
it('renders label elements for all labels', () => {
const labelsEl = wrapper.findAll('.dropdown-content li');
const labelItemEl = labelsEl.at(0).find(GlLink);
expect(labelsEl.length).toBe(mockLabels.length);
expect(labelItemEl.exists()).toBe(true);
expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close');
expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe(
'background-color: rgb(186, 218, 85);',
);
expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title);
});
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => {
wrapper.setData({
currentHighlightItem: 0,
});
return wrapper.vm.$nextTick(() => {
const labelsEl = wrapper.findAll('.dropdown-content li');
const labelItemEl = labelsEl.at(0).find(GlLink);
expect(labelItemEl.attributes('class')).toContain('is-focused');
});
});
it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
wrapper.setData({
searchKey: 'abc',
});
return wrapper.vm.$nextTick(() => {
const noMatchEl = wrapper.find('.dropdown-content li');
expect(noMatchEl.exists()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
it('renders footer list items', () => {
const createLabelBtn = wrapper.find('.dropdown-footer').find(GlButton);
const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink);
expect(createLabelBtn.exists()).toBe(true);
expect(createLabelBtn.text()).toBe('Create label');
expect(manageLabelsLink.exists()).toBe(true);
expect(manageLabelsLink.text()).toBe('Manage labels');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContents, {
localVue,
store,
});
};
describe('DropdownContent', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('dropdownContentsView', () => {
it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
});
it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
});
});
});
describe('template', () => {
it('renders component container element with class `labels-select-dropdown-contents`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownTitle, {
localVue,
store,
propsData: {
labelsSelectInProgress: false,
},
});
};
describe('DropdownTitle', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with string "Labels"', () => {
expect(wrapper.text()).toContain('Labels');
});
it('renders edit link', () => {
const editBtnEl = wrapper.find(GlButton);
expect(editBtnEl.exists()).toBe(true);
expect(editBtnEl.text()).toBe('Edit');
});
it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
wrapper.setProps({
labelsSelectInProgress: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
});
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLabel } from '@gitlab/ui';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig, slots = {}) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownValue, {
localVue,
store,
slots,
});
};
describe('DropdownValue', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('labelFilterUrl', () => {
it('returns a label filter URL based on provided label param', () => {
expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
'/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
);
});
});
describe('scopedLabel', () => {
it('returns `true` when provided label param is a scoped label', () => {
expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
});
it('returns `false` when provided label param is a regular label', () => {
expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
});
});
});
describe('template', () => {
it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
expect(wrapper.attributes('class')).toContain('has-labels');
});
it('renders element containing `None` when `selectedLabels` is empty', () => {
const wrapperNoLabels = createComponent(
{
...mockConfig,
selectedLabels: [],
},
{
default: 'None',
},
);
const noneEl = wrapperNoLabels.find('span.text-secondary');
expect(noneEl.exists()).toBe(true);
expect(noneEl.text()).toBe('None');
wrapperNoLabels.destroy();
});
it('renders labels when `selectedLabels` is not empty', () => {
expect(wrapper.findAll(GlLabel).length).toBe(2);
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (config = mockConfig, slots = {}) =>
shallowMount(LabelsSelectRoot, {
localVue,
slots,
store: new Vuex.Store(labelsSelectModule()),
propsData: config,
});
describe('LabelsSelectRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleVuexActionDispatch', () => {
it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
wrapper.vm.handleVuexActionDispatch(
{ type: 'toggleDropdownContents' },
{
showDropdownButton: false,
showDropdownContents: false,
labels: [{ id: 1 }, { id: 2, touched: true }],
},
);
expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
expect.arrayContaining([
{
id: 2,
touched: true,
},
]),
);
});
});
describe('handleDropdownClose', () => {
it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
wrapper.vm.handleDropdownClose([]);
expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
});
describe('handleCollapsedValueClick', () => {
it('emits `toggleCollapse` event on component', () => {
wrapper.vm.handleCollapsedValueClick();
expect(wrapper.emitted().toggleCollapse).toBeTruthy();
});
});
});
describe('template', () => {
it('renders component with classes `labels-select-wrapper position-relative`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
it('renders `dropdown-title` component', () => {
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
const valueComp = wrapperDropdownValue.find(DropdownValue);
expect(valueComp.exists()).toBe(true);
expect(valueComp.text()).toBe('None');
wrapperDropdownValue.destroy();
});
it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownButton');
expect(wrapper.find(DropdownButton).exists()).toBe(true);
});
it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownContents');
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(DropdownContents).exists()).toBe(true);
});
});
});
});
export const mockRegularLabel = {
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
textColor: '#FFFFFF',
};
export const mockScopedLabel = {
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#0033CC',
textColor: '#FFFFFF',
};
export const mockLabels = [
mockRegularLabel,
mockScopedLabel,
{
id: 28,
title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
},
];
export const mockConfig = {
allowLabelEdit: true,
allowLabelCreate: true,
allowScopedLabels: true,
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
dropdownOnly: false,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium',
};
export const mockSuggestedColors = {
'#0033CC': 'UA blue',
'#428BCA': 'Moderate blue',
'#44AD8E': 'Lime green',
'#A8D695': 'Feijoa',
'#5CB85C': 'Slightly desaturated green',
'#69D100': 'Bright green',
'#004E00': 'Very dark lime green',
'#34495E': 'Very dark desaturated blue',
'#7F8C8D': 'Dark grayish cyan',
'#A295D6': 'Slightly desaturated blue',
'#5843AD': 'Dark moderate blue',
'#8E44AD': 'Dark moderate violet',
'#FFECDB': 'Very pale orange',
'#AD4363': 'Dark moderate pink',
'#D10069': 'Strong pink',
'#CC0033': 'Strong red',
'#FF0000': 'Pure red',
'#D9534F': 'Soft red',
'#D1D100': 'Strong yellow',
'#F0AD4E': 'Soft orange',
'#AD8D43': 'Dark moderate orange',
};
import MockAdapter from 'axios-mock-adapter';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types';
import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
describe('LabelsSelect Actions', () => {
let state;
const mockInitialState = {
labels: [],
selectedLabels: [],
};
beforeEach(() => {
state = Object.assign({}, defaultState());
});
describe('setInitialState', () => {
it('sets initial store state', done => {
testAction(
actions.setInitialState,
mockInitialState,
state,
[{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
[],
done,
);
});
});
describe('toggleDropdownButton', () => {
it('toggles dropdown button', done => {
testAction(
actions.toggleDropdownButton,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_BUTTON }],
[],
done,
);
});
});
describe('toggleDropdownContents', () => {
it('toggles dropdown contents', done => {
testAction(
actions.toggleDropdownContents,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
[],
done,
);
});
});
describe('toggleDropdownContentsCreateView', () => {
it('toggles dropdown create view', done => {
testAction(
actions.toggleDropdownContentsCreateView,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
[],
done,
);
});
});
describe('requestLabels', () => {
it('sets value of `state.labelsFetchInProgress` to `true`', done => {
testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
});
});
describe('receiveLabelsSuccess', () => {
it('sets provided labels to `state.labels`', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.receiveLabelsSuccess,
labels,
state,
[{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
[],
done,
);
});
});
describe('receiveLabelsFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('sets value `state.labelsFetchInProgress` to `false`', done => {
testAction(
actions.receiveLabelsFailure,
{},
state,
[{ type: types.RECEIVE_SET_LABELS_FAILURE }],
[],
done,
);
});
it('shows flash error', () => {
actions.receiveLabelsFailure({ commit: () => {} });
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error fetching labels.',
);
});
});
describe('fetchLabels', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.labelsFetchPath = 'labels.json';
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
mock.onGet(/labels.json/).replyOnce(200, labels);
testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
done,
);
});
});
describe('on failure', () => {
it('dispatches `requestLabels` & `receiveLabelsFailure` actions', done => {
mock.onGet(/labels.json/).replyOnce(500, {});
testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
done,
);
});
});
});
describe('requestCreateLabel', () => {
it('sets value `state.labelCreateInProgress` to `true`', done => {
testAction(
actions.requestCreateLabel,
{},
state,
[{ type: types.REQUEST_CREATE_LABEL }],
[],
done,
);
});
});
describe('receiveCreateLabelSuccess', () => {
it('sets value `state.labelCreateInProgress` to `false`', done => {
testAction(
actions.receiveCreateLabelSuccess,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }],
[],
done,
);
});
});
describe('receiveCreateLabelFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('sets value `state.labelCreateInProgress` to `false`', done => {
testAction(
actions.receiveCreateLabelFailure,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_FAILURE }],
[],
done,
);
});
it('shows flash error', () => {
actions.receiveCreateLabelFailure({ commit: () => {} });
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error creating label.',
);
});
});
describe('createLabel', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.labelsManagePath = 'labels.json';
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
it('dispatches `requestCreateLabel`, `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', done => {
const label = { id: 1 };
mock.onPost(/labels.json/).replyOnce(200, label);
testAction(
actions.createLabel,
{},
state,
[],
[
{ type: 'requestCreateLabel' },
{ type: 'receiveCreateLabelSuccess' },
{ type: 'toggleDropdownContentsCreateView' },
],
done,
);
});
});
describe('on failure', () => {
it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', done => {
mock.onPost(/labels.json/).replyOnce(500, {});
testAction(
actions.createLabel,
{},
state,
[],
[{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }],
done,
);
});
});
});
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.updateSelectedLabels,
labels,
state,
[{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
[],
done,
);
});
});
});
import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters';
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 })).toBe('Label');
});
it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }];
expect(getters.dropdownButtonText({ labels })).toBe('Foobar');
});
it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }];
expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more');
});
});
describe('selectedLabelsList', () => {
it('returns array of IDs of all labels within `state.selectedLabels`', () => {
const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
});
});
});
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types';
describe('LabelsSelect Mutations', () => {
describe(`${types.SET_INITIAL_STATE}`, () => {
it('initializes provided props to store state', () => {
const state = {};
mutations[types.SET_INITIAL_STATE](state, {
labels: 'foo',
});
expect(state.labels).toEqual('foo');
});
});
describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
it('toggles value of `state.showDropdownButton`', () => {
const state = {
showDropdownButton: false,
};
mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
expect(state.showDropdownButton).toBe(true);
});
});
describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
const state = {
dropdownOnly: false,
showDropdownButton: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownButton).toBe(true);
});
it('toggles value of `state.showDropdownContents`', () => {
const state = {
showDropdownContents: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownContents).toBe(true);
});
it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
const state = {
showDropdownContents: false,
showDropdownContentsCreateView: true,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownContentsCreateView).toBe(false);
});
});
describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
it('toggles value of `state.showDropdownContentsCreateView`', () => {
const state = {
showDropdownContentsCreateView: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
expect(state.showDropdownContentsCreateView).toBe(true);
});
});
describe(`${types.REQUEST_LABELS}`, () => {
it('sets value of `state.labelsFetchInProgress` to true', () => {
const state = {
labelsFetchInProgress: false,
};
mutations[types.REQUEST_LABELS](state);
expect(state.labelsFetchInProgress).toBe(true);
});
});
describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
const selectedLabels = [{ id: 2 }, { id: 4 }];
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('sets value of `state.labelsFetchInProgress` to false', () => {
const state = {
selectedLabels,
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
expect(state.labelsFetchInProgress).toBe(false);
});
it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
const selectedLabelIds = selectedLabels.map(label => label.id);
const state = {
selectedLabels,
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
state.labels.forEach(label => {
if (selectedLabelIds.includes(label.id)) {
expect(label.set).toBe(true);
}
});
});
});
describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
it('sets value of `state.labelsFetchInProgress` to false', () => {
const state = {
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
expect(state.labelsFetchInProgress).toBe(false);
});
});
describe(`${types.REQUEST_CREATE_LABEL}`, () => {
it('sets value of `state.labelCreateInProgress` to true', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.REQUEST_CREATE_LABEL](state);
expect(state.labelCreateInProgress).toBe(true);
});
});
describe(`${types.RECEIVE_CREATE_LABEL_SUCCESS}`, () => {
it('sets value of `state.labelCreateInProgress` to false', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.RECEIVE_CREATE_LABEL_SUCCESS](state);
expect(state.labelCreateInProgress).toBe(false);
});
});
describe(`${types.RECEIVE_CREATE_LABEL_FAILURE}`, () => {
it('sets value of `state.labelCreateInProgress` to false', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.RECEIVE_CREATE_LABEL_FAILURE](state);
expect(state.labelCreateInProgress).toBe(false);
});
});
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
const updatedLabelIds = [2, 4];
const state = {
labels,
};
mutations[types.UPDATE_SELECTED_LABELS](state, { labels });
state.labels.forEach(label => {
if (updatedLabelIds.includes(label.id)) {
expect(label.touched).toBe(true);
expect(label.set).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