Commit a89d86f0 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '198431-vue-labels-dropdown' into 'master'

Vue Labels Select Dropdown

Closes #198431

See merge request gitlab-org/gitlab!24576
parents 7663814f 004b6e12
...@@ -27,7 +27,12 @@ export default { ...@@ -27,7 +27,12 @@ export default {
<span :style="labelStyle" class="badge color-label"> <span :style="labelStyle" class="badge color-label">
{{ label.title }} {{ label.title }}
</span> </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 }} {{ label.description }}
</gl-tooltip> </gl-tooltip>
</a> </a>
......
...@@ -33,7 +33,12 @@ export default { ...@@ -33,7 +33,12 @@ export default {
<span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
{{ label.title }} {{ label.title }}
</span> </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 <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
><br /> ><br />
{{ label.description }} {{ 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 { ...@@ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
opacity: 0; 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 "" ...@@ -7778,6 +7778,9 @@ msgstr ""
msgid "Error creating epic" msgid "Error creating epic"
msgstr "" msgstr ""
msgid "Error creating label."
msgstr ""
msgid "Error deleting %{issuableType}" msgid "Error deleting %{issuableType}"
msgstr "" msgstr ""
...@@ -21315,6 +21318,9 @@ 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)." msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr "" msgstr ""
msgid "Use custom color #FF0000"
msgstr ""
msgid "Use group milestones to manage issues from multiple projects in the same milestone." msgid "Use group milestones to manage issues from multiple projects in the same milestone."
msgstr "" 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