Commit 1d59a6fb authored by Simon Knox's avatar Simon Knox Committed by Kushal Pandya

Split new column form into data/display components

Makes it easier to extend for different types of list
in EE
parent 827fcc6a
<script>
import {
GlButton,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
GlLabel,
GlSearchBoxByType,
GlSkeletonLoader,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import boardsStore from '../stores/boards_store';
export default {
i18n: {
add: __('Add'),
cancel: __('Cancel'),
formDescription: __('A label list displays all issues with the selected label.'),
newLabelList: __('New label list'),
noLabelSelected: __('No label selected'),
searchPlaceholder: __('Search labels'),
selectLabel: __('Select label'),
selected: __('Selected'),
},
components: {
GlButton,
GlFormGroup,
BoardAddNewColumnForm,
GlFormRadio,
GlFormRadioGroup,
GlLabel,
GlSearchBoxByType,
GlSkeletonLoader,
},
directives: {
GlTooltip,
......@@ -40,31 +25,27 @@ export default {
inject: ['scopedLabelsAvailable'],
data() {
return {
searchTerm: '',
selectedLabelId: null,
selectedId: null,
};
},
computed: {
...mapState(['labels', 'labelsLoading', 'isEpicBoard']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
selectedLabel() {
return this.labels.find(({ id }) => id === this.selectedLabelId);
if (!this.selectedId) {
return null;
}
return this.labels.find(({ id }) => id === this.selectedId);
},
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
},
created() {
this.filterLabels();
this.filterItems();
},
methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
getListByLabel(label) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
return this.getListByLabelId(label);
}
return boardsStore.findListByLabelId(label.id);
},
columnExists(label) {
return Boolean(this.getListByLabel(label));
},
highlight(listId) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
this.highlightList(listId);
......@@ -77,44 +58,35 @@ export default {
}
},
addList() {
if (!this.selectedLabelId) {
return;
}
const label = this.selectedLabel;
if (!label) {
if (!this.selectedLabel) {
return;
}
this.setAddColumnFormVisibility(false);
if (this.columnExists({ id: this.selectedLabelId })) {
const listId = this.getListByLabel(label).id;
if (this.columnForSelected) {
const listId = this.columnForSelected.id;
this.highlight(listId);
return;
}
if (this.shouldUseGraphQL || this.isEpicBoard) {
this.createList({ labelId: this.selectedLabelId });
this.createList({ labelId: this.selectedId });
} else {
boardsStore.new({
title: label.title,
const listObj = {
labelId: getIdFromGraphQLId(this.selectedId),
title: this.selectedLabel.title,
position: boardsStore.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
title: label.title,
color: label.color,
},
});
list_type: ListType.label,
label: this.selectedLabel,
};
this.highlight(boardsStore.findListByLabelId(label.id).id);
boardsStore.new(listObj);
}
},
filterLabels() {
this.fetchLabels(this.searchTerm);
filterItems(searchTerm) {
this.fetchLabels(searchTerm);
},
showScopedLabels(label) {
......@@ -125,29 +97,16 @@ export default {
</script>
<template>
<div
class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
data-testid="board-add-new-column"
data-qa-selector="board_add_new_list"
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
<board-add-new-column-form
:loading="labelsLoading"
:form-description="__('A label list displays issues with the selected label.')"
:search-label="__('Select label')"
:search-placeholder="__('Search labels')"
:selected-id="selectedId"
@filter-items="filterItems"
@add-list="addList"
>
<h3
class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
data-testid="board-add-column-form-title"
>
{{ $options.i18n.newLabelList }}
</h3>
<div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
<!-- selectbox is here in EE -->
<p class="gl-m-5">{{ $options.i18n.formDescription }}</p>
<div class="gl-px-5 gl-pb-4">
<label class="gl-mb-2">{{ $options.i18n.selected }}</label>
<div>
<template slot="selected">
<gl-label
v-if="selectedLabel"
v-gl-tooltip
......@@ -156,37 +115,10 @@ export default {
:background-color="selectedLabel.color"
:scoped="showScopedLabels(selectedLabel)"
/>
<div v-else class="gl-text-gray-500">{{ $options.i18n.noLabelSelected }}</div>
</div>
</div>
<gl-form-group
class="gl-mx-5 gl-mb-3"
:label="$options.i18n.selectLabel"
label-for="board-available-labels"
>
<gl-search-box-by-type
id="board-available-labels"
v-model.trim="searchTerm"
debounce="250"
:placeholder="$options.i18n.searchPlaceholder"
@input="filterLabels"
/>
</gl-form-group>
</template>
<div v-if="labelsLoading" class="gl-m-5">
<gl-skeleton-loader :width="500" :height="172">
<rect width="480" height="20" x="10" y="15" rx="4" />
<rect width="380" height="20" x="10" y="50" rx="4" />
<rect width="430" height="20" x="10" y="85" rx="4" />
</gl-skeleton-loader>
</div>
<gl-form-radio-group
v-else
v-model="selectedLabelId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
>
<template slot="items">
<gl-form-radio-group v-model="selectedId" class="gl-overflow-y-auto gl-px-5 gl-pt-3">
<label
v-for="label in labels"
:key="label.id"
......@@ -202,26 +134,6 @@ export default {
<span>{{ label.title }}</span>
</label>
</gl-form-radio-group>
</div>
<div
class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10"
>
<gl-button
data-testid="cancelAddNewColumn"
class="gl-ml-auto gl-mr-3"
@click="setAddColumnFormVisibility(false)"
>{{ $options.i18n.cancel }}</gl-button
>
<gl-button
data-testid="addNewColumnButton"
:disabled="!selectedLabelId"
variant="success"
class="gl-mr-4"
@click="addList"
>{{ $options.i18n.add }}</gl-button
>
</div>
</div>
</div>
</template>
</board-add-new-column-form>
</template>
<script>
import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
export default {
i18n: {
add: __('Add'),
cancel: __('Cancel'),
newList: __('New list'),
noneSelected: __('None'),
selected: __('Selected'),
},
components: {
GlButton,
GlFormGroup,
GlSearchBoxByType,
GlSkeletonLoader,
},
props: {
loading: {
type: Boolean,
required: true,
},
formDescription: {
type: String,
required: true,
},
searchLabel: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
required: true,
},
selectedId: {
type: [Number, String],
required: false,
default: null,
},
},
methods: {
...mapActions(['setAddColumnFormVisibility']),
},
};
</script>
<template>
<div
class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0"
data-testid="board-add-new-column"
data-qa-selector="board_add_new_list"
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
>
<h3
class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
data-testid="board-add-column-form-title"
>
{{ $options.i18n.newList }}
</h3>
<div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
<slot name="select-list-type">
<div class="gl-mb-5"></div>
</slot>
<p class="gl-px-5">{{ formDescription }}</p>
<div class="gl-px-5 gl-pb-4">
<label class="gl-mb-2">{{ $options.i18n.selected }}</label>
<slot name="selected">
<div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div>
</slot>
</div>
<gl-form-group
class="gl-mx-5 gl-mb-3"
:label="searchLabel"
label-for="board-available-column-entities"
>
<gl-search-box-by-type
id="board-available-column-entities"
debounce="250"
:placeholder="searchPlaceholder"
@input="$emit('filter-items', $event)"
/>
</gl-form-group>
<div v-if="loading" class="gl-px-5">
<gl-skeleton-loader :width="500" :height="172">
<rect width="480" height="20" x="10" y="15" rx="4" />
<rect width="380" height="20" x="10" y="50" rx="4" />
<rect width="430" height="20" x="10" y="85" rx="4" />
</gl-skeleton-loader>
</div>
<slot v-else name="items"></slot>
</div>
<div
class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10"
>
<gl-button
data-testid="cancelAddNewColumn"
class="gl-ml-auto gl-mr-3"
@click="setAddColumnFormVisibility(false)"
>{{ $options.i18n.cancel }}</gl-button
>
<gl-button
data-testid="addNewColumnButton"
:disabled="!selectedId"
variant="success"
class="gl-mr-4"
@click="$emit('add-list')"
>{{ $options.i18n.add }}</gl-button
>
</div>
</div>
</div>
</template>
......@@ -157,8 +157,8 @@ export default {
},
})
.then(({ data }) => {
if (data?.boardListCreate?.errors.length) {
commit(types.CREATE_LIST_FAILURE);
if (data.boardListCreate?.errors.length) {
commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]);
} else {
const list = data.boardListCreate?.list;
dispatch('addList', list);
......
......@@ -60,8 +60,11 @@ export default {
state.filterParams = filterParams;
},
[mutationTypes.CREATE_LIST_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while creating the list. Please try again.');
[mutationTypes.CREATE_LIST_FAILURE]: (
state,
error = s__('Boards|An error occurred while creating the list. Please try again.'),
) => {
state.error = error;
},
[mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => {
......
......@@ -587,7 +587,7 @@ export default {
})
.then(({ data }) => {
if (data?.epicBoardListCreate?.errors.length) {
commit(types.CREATE_LIST_FAILURE);
commit(types.CREATE_LIST_FAILURE, data.epicBoardListCreate.errors[0]);
} else {
const list = data.epicBoardListCreate?.list;
dispatch('addList', list);
......
......@@ -1026,14 +1026,14 @@ describe('moveIssue', () => {
data: {
epicBoardListCreate: {
list: {},
errors: [{ foo: 'bar' }],
errors: ['foo'],
},
},
});
await actions.createEpicList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
});
it('highlights list and does not re-query if it already exists', async () => {
......
......@@ -1330,7 +1330,7 @@ msgstr ""
msgid "A job artifact is an archive of files and directories saved by a job when it finishes."
msgstr ""
msgid "A label list displays all issues with the selected label."
msgid "A label list displays issues with the selected label."
msgstr ""
msgid "A limit of %{ci_project_subscriptions_limit} subscriptions to or from a project applies."
......@@ -20298,7 +20298,7 @@ msgstr ""
msgid "New label"
msgstr ""
msgid "New label list"
msgid "New list"
msgstr ""
msgid "New merge request"
......@@ -20520,9 +20520,6 @@ msgstr ""
msgid "No label"
msgstr ""
msgid "No label selected"
msgstr ""
msgid "No labels with such name or description"
msgstr ""
......
import { GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import defaultState from '~/boards/stores/state';
import { mockLabelList } from '../mock_data';
Vue.use(Vuex);
describe('Board card layout', () => {
let wrapper;
const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
return new Vuex.Store({
state: {
...defaultState,
...state,
},
actions,
getters,
});
};
const mountComponent = ({
loading = false,
formDescription = '',
searchLabel = '',
searchPlaceholder = '',
selectedId,
actions,
slots,
} = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardAddNewColumnForm, {
stubs: {
GlFormGroup: true,
},
propsData: {
loading,
formDescription,
searchLabel,
searchPlaceholder,
selectedId,
},
slots,
store: createStore({
actions: {
setAddColumnFormVisibility: jest.fn(),
...actions,
},
}),
}),
);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text();
const findSearchInput = () => wrapper.find(GlSearchBoxByType);
const findSearchLabel = () => wrapper.find(GlFormGroup);
const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
const submitButton = () => wrapper.findByTestId('addNewColumnButton');
it('shows form title & search input', () => {
mountComponent();
expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList);
expect(findSearchInput().exists()).toBe(true);
});
it('clicking cancel hides the form', () => {
const setAddColumnFormVisibility = jest.fn();
mountComponent({
actions: {
setAddColumnFormVisibility,
},
});
cancelButton().vm.$emit('click');
expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
});
it('sets placeholder and description from props', () => {
const props = {
formDescription: 'Some description of a list',
};
mountComponent(props);
expect(wrapper.html()).toHaveText(props.formDescription);
});
describe('items', () => {
const mountWithItems = (loading) =>
mountComponent({
loading,
slots: {
items: '<div class="item-slot">Some kind of list</div>',
},
});
it('hides items slot and shows skeleton while loading', () => {
mountWithItems(true);
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
expect(wrapper.find('.item-slot').exists()).toBe(false);
});
it('shows items slot and hides skeleton while not loading', () => {
mountWithItems(false);
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
expect(wrapper.find('.item-slot').exists()).toBe(true);
});
});
describe('search box', () => {
it('sets label and placeholder text from props', () => {
const props = {
searchLabel: 'Some items',
searchPlaceholder: 'Search for an item',
};
mountComponent(props);
expect(findSearchLabel().attributes('label')).toEqual(props.searchLabel);
expect(findSearchInput().attributes('placeholder')).toEqual(props.searchPlaceholder);
});
it('emits filter event on input', () => {
mountComponent();
const searchText = 'some text';
findSearchInput().vm.$emit('input', searchText);
expect(wrapper.emitted('filter-items')).toEqual([[searchText]]);
});
});
describe('Add list button', () => {
it('is disabled if no item is selected', () => {
mountComponent();
expect(submitButton().props('disabled')).toBe(true);
});
it('emits add-list event on click', async () => {
mountComponent({
selectedId: mockLabelList.label.id,
});
await nextTick();
submitButton().vm.$emit('click');
expect(wrapper.emitted('add-list')).toEqual([[]]);
});
});
});
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import defaultState from '~/boards/stores/state';
import { mockLabelList } from '../mock_data';
......@@ -11,7 +11,6 @@ Vue.use(Vuex);
describe('Board card layout', () => {
let wrapper;
let shouldUseGraphQL;
const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
return new Vuex.Store({
......@@ -25,19 +24,16 @@ describe('Board card layout', () => {
};
const mountComponent = ({
selectedLabelId,
selectedId,
labels = [],
getListByLabelId = jest.fn(),
actions = {},
} = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardAddNewColumn, {
stubs: {
GlFormGroup: true,
},
data() {
return {
selectedLabelId,
selectedId,
};
},
store: createStore({
......@@ -47,12 +43,13 @@ describe('Board card layout', () => {
...actions,
},
getters: {
shouldUseGraphQL: () => shouldUseGraphQL,
shouldUseGraphQL: () => true,
getListByLabelId: () => getListByLabelId,
},
state: {
labels,
labelsLoading: false,
isEpicBoard: false,
},
}),
provide: {
......@@ -64,65 +61,32 @@ describe('Board card layout', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text();
const findSearchInput = () => wrapper.find(GlSearchBoxByType);
const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
const submitButton = () => wrapper.findByTestId('addNewColumnButton');
beforeEach(() => {
shouldUseGraphQL = true;
});
it('shows form title & search input', () => {
mountComponent();
expect(formTitle()).toEqual(BoardAddNewColumn.i18n.newLabelList);
expect(findSearchInput().exists()).toBe(true);
});
it('clicking cancel hides the form', () => {
const setAddColumnFormVisibility = jest.fn();
mountComponent({
actions: {
setAddColumnFormVisibility,
},
});
cancelButton().vm.$emit('click');
expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
});
describe('Add list button', () => {
it('is disabled if no item is selected', () => {
mountComponent();
expect(submitButton().props('disabled')).toBe(true);
});
it('adds a new list on click', async () => {
const labelId = mockLabelList.label.id;
it('calls addList', async () => {
const getListByLabelId = jest.fn().mockReturnValue(null);
const highlightList = jest.fn();
const createList = jest.fn();
mountComponent({
labels: [mockLabelList.label],
selectedLabelId: labelId,
selectedId: mockLabelList.label.id,
getListByLabelId,
actions: {
createList,
highlightList,
},
});
await nextTick();
wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
submitButton().vm.$emit('click');
await nextTick();
expect(highlightList).not.toHaveBeenCalled();
expect(createList).toHaveBeenCalledWith(expect.anything(), { labelId });
expect(createList).toHaveBeenCalledWith(expect.anything(), {
labelId: mockLabelList.label.id,
});
});
it('highlights existing list if trying to re-add', async () => {
......@@ -132,7 +96,7 @@ describe('Board card layout', () => {
mountComponent({
labels: [mockLabelList.label],
selectedLabelId: mockLabelList.label.id,
selectedId: mockLabelList.label.id,
getListByLabelId,
actions: {
createList,
......@@ -140,9 +104,9 @@ describe('Board card layout', () => {
},
});
await nextTick();
wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
submitButton().vm.$emit('click');
await nextTick();
expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id);
expect(createList).not.toHaveBeenCalled();
......
......@@ -293,7 +293,7 @@ describe('createIssueList', () => {
data: {
boardListCreate: {
list: {},
errors: [{ foo: 'bar' }],
errors: ['foo'],
},
},
}),
......@@ -301,7 +301,7 @@ describe('createIssueList', () => {
await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
});
it('highlights list and does not re-query if it already exists', async () => {
......
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