Commit 817bc627 authored by Savas Vedova's avatar Savas Vedova

Merge branch 'psimiles' into 'master'

Add milestone to new board new list form

See merge request gitlab-org/gitlab!55498
parents 35261b8b 5ddfe091
...@@ -30,7 +30,7 @@ export default { ...@@ -30,7 +30,7 @@ export default {
}, },
computed: { computed: {
...mapState(['labels', 'labelsLoading']), ...mapState(['labels', 'labelsLoading']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL', 'isEpicBoard']), ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
selectedLabel() { selectedLabel() {
if (!this.selectedId) { if (!this.selectedId) {
return null; return null;
...@@ -47,7 +47,7 @@ export default { ...@@ -47,7 +47,7 @@ export default {
methods: { methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
highlight(listId) { highlight(listId) {
if (this.shouldUseGraphQL || this.isEpicBoard) { if (this.shouldUseGraphQL) {
this.highlightList(listId); this.highlightList(listId);
} else { } else {
const list = boardsStore.state.lists.find(({ id }) => id === listId); const list = boardsStore.state.lists.find(({ id }) => id === listId);
...@@ -70,7 +70,7 @@ export default { ...@@ -70,7 +70,7 @@ export default {
return; return;
} }
if (this.shouldUseGraphQL || this.isEpicBoard) { if (this.shouldUseGraphQL) {
this.createList({ labelId: this.selectedId }); this.createList({ labelId: this.selectedId });
} else { } else {
const listObj = { const listObj = {
...@@ -118,13 +118,17 @@ export default { ...@@ -118,13 +118,17 @@ export default {
</template> </template>
<template slot="items"> <template slot="items">
<gl-form-radio-group v-model="selectedId" class="gl-overflow-y-auto gl-px-5 gl-pt-3"> <gl-form-radio-group
v-if="labels.length > 0"
v-model="selectedId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
>
<label <label
v-for="label in labels" v-for="label in labels"
:key="label.id" :key="label.id"
class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal" class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
> >
<gl-form-radio :value="label.id" class="gl-mb-0 gl-mr-3" /> <gl-form-radio :value="label.id" class="gl-mb-0" />
<span <span
class="dropdown-label-box gl-top-0" class="dropdown-label-box gl-top-0"
:style="{ :style="{
......
...@@ -5,10 +5,11 @@ import { __ } from '~/locale'; ...@@ -5,10 +5,11 @@ import { __ } from '~/locale';
export default { export default {
i18n: { i18n: {
add: __('Add'), add: __('Add to board'),
cancel: __('Cancel'), cancel: __('Cancel'),
newList: __('New list'), newList: __('New list'),
noneSelected: __('None'), noneSelected: __('None'),
noResults: __('No matching results'),
selected: __('Selected'), selected: __('Selected'),
}, },
components: { components: {
...@@ -40,6 +41,11 @@ export default { ...@@ -40,6 +41,11 @@ export default {
default: null, default: null,
}, },
}, },
data() {
return {
searchValue: '',
};
},
methods: { methods: {
...mapActions(['setAddColumnFormVisibility']), ...mapActions(['setAddColumnFormVisibility']),
}, },
...@@ -83,6 +89,7 @@ export default { ...@@ -83,6 +89,7 @@ export default {
> >
<gl-search-box-by-type <gl-search-box-by-type
id="board-available-column-entities" id="board-available-column-entities"
v-model="searchValue"
debounce="250" debounce="250"
:placeholder="searchPlaceholder" :placeholder="searchPlaceholder"
@input="$emit('filter-items', $event)" @input="$emit('filter-items', $event)"
...@@ -97,10 +104,12 @@ export default { ...@@ -97,10 +104,12 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</div> </div>
<slot v-else name="items"></slot> <slot v-else name="items">
<p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
</slot>
</div> </div>
<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" class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
> >
<gl-button <gl-button
data-testid="cancelAddNewColumn" data-testid="cancelAddNewColumn"
...@@ -111,7 +120,7 @@ export default { ...@@ -111,7 +120,7 @@ export default {
<gl-button <gl-button
data-testid="addNewColumnButton" data-testid="addNewColumnButton"
:disabled="!selectedId" :disabled="!selectedId"
variant="success" variant="confirm"
class="gl-mr-4" class="gl-mr-4"
@click="$emit('add-list')" @click="$emit('add-list')"
>{{ $options.i18n.add }}</gl-button >{{ $options.i18n.add }}</gl-button
......
...@@ -3,10 +3,10 @@ import { GlAlert } from '@gitlab/ui'; ...@@ -3,10 +3,10 @@ import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardAddNewColumn from './board_add_new_column.vue';
import BoardColumn from './board_column.vue'; import BoardColumn from './board_column.vue';
import BoardColumnDeprecated from './board_column_deprecated.vue'; import BoardColumnDeprecated from './board_column_deprecated.vue';
......
<script>
import {
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
GlFormSelect,
GlLabel,
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';
export default {
i18n: {
listType: __('List type'),
labelListDescription: __('A label list displays issues with the selected label.'),
milestoneListDescription: __('A milestone list displays issues in the selected milestone.'),
selectLabel: __('Select label'),
selectMilestone: __('Select milestone'),
searchLabels: __('Search labels'),
searchMilestones: __('Search milestones'),
},
columnTypes: [
{ value: ListType.label, text: __('Label') },
{ value: ListType.milestone, text: __('Milestone') },
],
components: {
BoardAddNewColumnForm,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
GlFormSelect,
GlLabel,
},
directives: {
GlTooltip,
},
inject: ['scopedLabelsAvailable'],
data() {
return {
selectedId: null,
columnType: ListType.label,
};
},
computed: {
...mapState(['labels', 'labelsLoading', 'milestones', 'milestonesLoading']),
...mapGetters(['getListByTypeId', 'shouldUseGraphQL', 'isEpicBoard']),
items() {
if (this.labelTypeSelected) {
return this.labels;
}
if (this.milestoneTypeSelected) {
return this.milestones;
}
return [];
},
labelTypeSelected() {
return this.columnType === ListType.label;
},
milestoneTypeSelected() {
return this.columnType === ListType.milestone;
},
selectedLabel() {
if (!this.labelTypeSelected) {
return null;
}
return this.labels.find(({ id }) => id === this.selectedId);
},
selectedMilestone() {
if (!this.milestoneTypeSelected) {
return null;
}
return this.milestones.find(({ id }) => id === this.selectedId);
},
selectedItem() {
if (!this.selectedId) {
return null;
}
if (this.labelTypeSelected) {
return this.selectedLabel;
}
if (this.milestoneTypeSelected) {
return this.selectedMilestone;
}
return null;
},
columnForSelected() {
if (!this.columnType) {
return false;
}
const key = `${this.columnType}Id`;
return this.getListByTypeId({
[key]: this.selectedId,
});
},
loading() {
if (this.columnType === ListType.label) {
return this.labelsLoading;
}
if (this.columnType === ListType.milestone) {
return this.milestonesLoading;
}
return false;
},
formDescription() {
if (this.labelTypeSelected) {
return this.$options.i18n.labelListDescription;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.milestoneListDescription;
}
return null;
},
searchLabel() {
if (this.labelTypeSelected) {
return this.$options.i18n.selectLabel;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.selectMilestone;
}
return null;
},
searchPlaceholder() {
if (this.labelTypeSelected) {
return this.$options.i18n.searchLabels;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.searchMilestones;
}
return null;
},
},
created() {
this.filterItems();
},
methods: {
...mapActions([
'createList',
'fetchLabels',
'highlightList',
'setAddColumnFormVisibility',
'fetchMilestones',
]),
highlight(listId) {
if (this.shouldUseGraphQL || this.isEpicBoard) {
this.highlightList(listId);
} else {
const list = boardsStore.state.lists.find(({ id }) => id === listId);
list.highlighted = true;
setTimeout(() => {
list.highlighted = false;
}, 2000);
}
},
addList() {
if (!this.selectedItem) {
return;
}
this.setAddColumnFormVisibility(false);
if (this.columnForSelected) {
const listId = this.columnForSelected.id;
this.highlight(listId);
return;
}
if (this.shouldUseGraphQL || this.isEpicBoard) {
// eslint-disable-next-line @gitlab/require-i18n-strings
this.createList({ [`${this.columnType}Id`]: this.selectedId });
} else {
const { length } = boardsStore.state.lists;
const position = this.hideClosed ? length - 1 : length - 2;
const listObj = {
// eslint-disable-next-line @gitlab/require-i18n-strings
[`${this.columnType}Id`]: getIdFromGraphQLId(this.selectedId),
title: this.selectedItem.title,
position,
list_type: this.columnType,
};
if (this.labelTypeSelected) {
listObj.label = this.selectedLabel;
} else if (this.milestoneTypeSelected) {
listObj.milestone = {
...this.selectedMilestone,
id: getIdFromGraphQLId(this.selectedMilestone.id),
};
}
boardsStore.new(listObj);
}
},
filterItems(searchTerm) {
switch (this.columnType) {
case ListType.milestone:
this.fetchMilestones(searchTerm);
break;
case ListType.label:
default:
this.fetchLabels(searchTerm);
}
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
setColumnType() {
this.selectedId = null;
this.filterItems();
},
},
};
</script>
<template>
<board-add-new-column-form
:loading="loading"
:form-description="formDescription"
:search-label="searchLabel"
:search-placeholder="searchPlaceholder"
:selected-id="selectedId"
@filter-items="filterItems"
@add-list="addList"
>
<template slot="select-list-type">
<gl-form-group
v-if="!isEpicBoard"
:label="$options.i18n.listType"
class="gl-px-5 gl-py-0 gl-mt-5"
label-for="list-type"
>
<gl-form-select
id="list-type"
v-model="columnType"
:options="$options.columnTypes"
@change="setColumnType"
/>
</gl-form-group>
</template>
<template slot="selected">
<div v-if="selectedLabel">
<gl-label
v-gl-tooltip
:title="selectedLabel.title"
:description="selectedLabel.description"
:background-color="selectedLabel.color"
:scoped="showScopedLabels(selectedLabel)"
/>
</div>
<div v-else-if="selectedMilestone" class="gl-text-truncate">
{{ selectedMilestone.title }}
</div>
</template>
<template slot="items">
<gl-form-radio-group
v-if="items.length > 0"
v-model="selectedId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
>
<label
v-for="item in items"
:key="item.id"
class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
>
<gl-form-radio :value="item.id" class="gl-mb-0" />
<span
v-if="labelTypeSelected"
class="dropdown-label-box gl-top-0"
:style="{
backgroundColor: item.color,
}"
></span>
<span>{{ item.title }}</span>
</label>
</gl-form-radio-group>
</template>
</board-add-new-column-form>
</template>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { isListDraggable } from '~/boards/boards_util'; import { isListDraggable } from '~/boards/boards_util';
import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import { DRAGGABLE_TAG } from '../constants'; import { DRAGGABLE_TAG } from '../constants';
...@@ -48,10 +48,9 @@ export default { ...@@ -48,10 +48,9 @@ export default {
return (listId) => this.getUnassignedIssues(listId); return (listId) => this.getUnassignedIssues(listId);
}, },
unassignedIssuesCount() { unassignedIssuesCount() {
return this.lists.reduce( return this.lists.reduce((total, list) => {
(total, list) => total + this.listsFlags[list.id]?.unassignedIssuesCount || 0, return total + (this.listsFlags[list.id]?.unassignedIssuesCount || 0);
0, }, 0);
);
}, },
unassignedIssuesCountTooltipText() { unassignedIssuesCountTooltipText() {
return n__(`%d unassigned issue`, `%d unassigned issues`, this.unassignedIssuesCount); return n__(`%d unassigned issue`, `%d unassigned issues`, this.unassignedIssuesCount);
......
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) {
group(fullPath: $fullPath) {
# TODO: add includeAncestors: https://gitlab.com/gitlab-org/gitlab/-/issues/323433
milestones(searchTitle: $searchTerm) {
nodes {
id
title
}
}
}
}
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) {
project(fullPath: $fullPath) {
milestones(searchTitle: $searchTerm, includeAncestors: true) {
nodes {
id
title
}
}
}
}
...@@ -33,11 +33,13 @@ import epicQuery from '../graphql/epic.query.graphql'; ...@@ -33,11 +33,13 @@ import epicQuery from '../graphql/epic.query.graphql';
import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql'; import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql';
import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql'; import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql'; import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql';
import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql'; import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql';
import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql'; import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql';
import listsEpicsQuery from '../graphql/lists_epics.query.graphql'; import listsEpicsQuery from '../graphql/lists_epics.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import updateBoardEpicUserPreferencesMutation from '../graphql/updateBoardEpicUserPreferences.mutation.graphql'; import updateBoardEpicUserPreferencesMutation from '../graphql/updateBoardEpicUserPreferences.mutation.graphql';
import boardsStoreEE from './boards_store_ee'; import boardsStoreEE from './boards_store_ee';
...@@ -557,6 +559,50 @@ export default { ...@@ -557,6 +559,50 @@ export default {
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
}, },
fetchMilestones({ state, commit }, searchTerm) {
commit(types.RECEIVE_MILESTONES_REQUEST);
const { fullPath, boardType } = state;
const variables = {
fullPath,
searchTerm,
};
let query;
if (boardType === BoardType.project) {
query = projectBoardMilestonesQuery;
}
if (boardType === BoardType.group) {
query = groupBoardMilestonesQuery;
}
if (!query) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unknown board type');
}
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const errors = data[boardType]?.errors;
const milestones = data[boardType]?.milestones.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
}
commit(types.RECEIVE_MILESTONES_SUCCESS, milestones);
})
.catch((e) => {
commit(types.RECEIVE_MILESTONES_FAILURE);
throw e;
});
},
createList: ({ getters, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { createList: ({ getters, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
if (!getters.isEpicBoard) { if (!getters.isEpicBoard) {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId }); dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
......
import { issuableTypes } from '~/boards/constants'; import { find } from 'lodash';
import { issuableTypes, ListType } from '~/boards/constants';
import gettersCE from '~/boards/stores/getters'; import gettersCE from '~/boards/stores/getters';
export default { export default {
...@@ -7,6 +8,32 @@ export default { ...@@ -7,6 +8,32 @@ export default {
isSwimlanesOn: (state) => { isSwimlanesOn: (state) => {
return Boolean(gon?.features?.swimlanes && state.isShowingEpicsSwimlanes); return Boolean(gon?.features?.swimlanes && state.isShowingEpicsSwimlanes);
}, },
getListByTypeId: (state) => ({ assigneeId, labelId, milestoneId }) => {
if (assigneeId) {
return find(
state.boardLists,
(l) => l.listType === ListType.assignee && l.assignee?.id === assigneeId,
);
}
if (labelId) {
return find(
state.boardLists,
(l) => l.listType === ListType.label && l.label?.id === labelId,
);
}
if (milestoneId) {
return find(
state.boardLists,
(l) => l.listType === ListType.milestone && l.milestone?.id === milestoneId,
);
}
return null;
},
getIssuesByEpic: (state, getters) => (listId, epicId) => { getIssuesByEpic: (state, getters) => (listId, epicId) => {
return getters return getters
.getBoardItemsByList(listId) .getBoardItemsByList(listId)
......
...@@ -33,3 +33,6 @@ export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; ...@@ -33,3 +33,6 @@ export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES'; export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES';
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
...@@ -3,7 +3,7 @@ import Vue from 'vue'; ...@@ -3,7 +3,7 @@ import Vue from 'vue';
import { moveIssueListHelper } from '~/boards/boards_util'; import { moveIssueListHelper } from '~/boards/boards_util';
import { issuableTypes } from '~/boards/constants'; import { issuableTypes } from '~/boards/constants';
import mutationsCE, { addIssueToList, removeIssueFromList } from '~/boards/stores/mutations'; import mutationsCE, { addIssueToList, removeIssueFromList } from '~/boards/stores/mutations';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import { ErrorMessages } from '../constants'; import { ErrorMessages } from '../constants';
import * as mutationTypes from './mutation_types'; import * as mutationTypes from './mutation_types';
...@@ -189,4 +189,18 @@ export default { ...@@ -189,4 +189,18 @@ export default {
Vue.set(epic, 'userPreferences', userPreferences); Vue.set(epic, 'userPreferences', userPreferences);
} }
}, },
[mutationTypes.RECEIVE_MILESTONES_REQUEST](state) {
state.milestonesLoading = true;
},
[mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) {
state.milestones = milestones;
state.milestonesLoading = false;
},
[mutationTypes.RECEIVE_MILESTONES_FAILURE](state) {
state.milestonesLoading = false;
state.error = __('Failed to load milestones.');
},
}; };
...@@ -12,4 +12,6 @@ export default () => ({ ...@@ -12,4 +12,6 @@ export default () => ({
epicsCacheById: {}, epicsCacheById: {},
epicFetchInProgress: false, epicFetchInProgress: false,
epicsFlags: {}, epicsFlags: {},
milestones: [],
milestonesLoading: false,
}); });
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User adds milestone lists', :js do
using RSpec::Parameterized::TableSyntax
let_it_be(:group) { create(:group, :nested) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:group_board) { create(:board, group: group) }
let_it_be(:project_board) { create(:board, project: project) }
let_it_be(:user) { create(:user) }
let_it_be(:milestone) { create(:milestone, group: group) }
let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) }
let_it_be(:issue) { create(:issue, project: project, milestone: milestone) }
before_all do
project.add_maintainer(user)
group.add_owner(user)
end
where(:board_type, :graphql_board_lists_enabled) do
:project | true
:project | false
:group | true
:group | false
end
with_them do
before do
stub_licensed_features(board_milestone_lists: true)
sign_in(user)
set_cookie('sidebar_collapsed', 'true')
stub_feature_flags(
graphql_board_lists: graphql_board_lists_enabled,
board_new_list: true
)
if board_type == :project
visit project_board_path(project, project_board)
elsif board_type == :group
visit group_board_path(group, group_board)
end
wait_for_all_requests
end
it 'creates milestone column' do
click_button button_text
wait_for_all_requests
select('Milestone', from: 'List type')
add_milestone_list(milestone)
wait_for_all_requests
expect(page).to have_selector('.board', text: milestone.title)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title)
end
end
def add_milestone_list(milestone)
page.within('.board-add-new-list') do
find('label', text: milestone.title).click
click_button 'Add'
end
end
def button_text
'Create list'
end
end
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import BoardAddNewColumn from 'ee/boards/components/board_add_new_column.vue';
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 { mockLists } from '../mock_data';
const mockLabelList = mockLists[1];
Vue.use(Vuex);
describe('Board card layout', () => {
let wrapper;
let shouldUseGraphQL;
const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
return new Vuex.Store({
state: {
...defaultState,
...state,
},
actions,
getters,
});
};
const mountComponent = ({
selectedId,
labels = [],
getListByTypeId = jest.fn(),
actions = {},
} = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardAddNewColumn, {
stubs: {
BoardAddNewColumnForm,
},
data() {
return {
selectedId,
};
},
store: createStore({
actions: {
fetchLabels: jest.fn(),
setAddColumnFormVisibility: jest.fn(),
...actions,
},
getters: {
shouldUseGraphQL: () => shouldUseGraphQL,
getListByTypeId: () => getListByTypeId,
isEpicBoard: () => false,
},
state: {
labels,
labelsLoading: false,
},
}),
provide: {
scopedLabelsAvailable: true,
},
}),
);
};
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(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);
});
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;
const highlightList = jest.fn();
const createList = jest.fn();
mountComponent({
labels: [mockLabelList.label],
selectedId: labelId,
actions: {
createList,
highlightList,
},
});
await nextTick();
submitButton().vm.$emit('click');
expect(highlightList).not.toHaveBeenCalled();
expect(createList).toHaveBeenCalledWith(expect.anything(), { labelId });
});
it('highlights existing list if trying to re-add', async () => {
const getListByTypeId = jest.fn().mockReturnValue(mockLabelList);
const highlightList = jest.fn();
const createList = jest.fn();
mountComponent({
labels: [mockLabelList.label],
selectedId: mockLabelList.label.id,
getListByTypeId,
actions: {
createList,
highlightList,
},
});
await nextTick();
submitButton().vm.$emit('click');
expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id);
expect(createList).not.toHaveBeenCalled();
});
});
});
...@@ -86,7 +86,7 @@ describe('EpicsSwimlanes', () => { ...@@ -86,7 +86,7 @@ describe('EpicsSwimlanes', () => {
}); });
it('displays BoardListHeader components for lists', () => { it('displays BoardListHeader components for lists', () => {
expect(wrapper.findAll(BoardListHeader)).toHaveLength(2); expect(wrapper.findAll(BoardListHeader)).toHaveLength(4);
}); });
it('displays EpicLane components for epic', () => { it('displays EpicLane components for epic', () => {
......
...@@ -36,6 +36,35 @@ export const mockLists = [ ...@@ -36,6 +36,35 @@ export const mockLists = [
milestone: null, milestone: null,
preset: false, preset: false,
}, },
{
id: 'gid://gitlab/List/3',
title: 'Assignee list',
position: 0,
listType: 'assignee',
collapsed: false,
label: null,
maxIssueCount: 0,
assignee: {
id: 'gid://gitlab/',
},
milestone: null,
preset: false,
},
{
id: 'gid://gitlab/List/4',
title: 'Milestone list',
position: 0,
listType: 'milestone',
collapsed: false,
label: null,
maxIssueCount: 0,
assignee: null,
milestone: {
id: 'gid://gitlab/Milestone/1',
title: 'A milestone',
},
preset: false,
},
]; ];
export const mockListsWithModel = mockLists.map((listMock) => export const mockListsWithModel = mockLists.map((listMock) =>
...@@ -57,6 +86,17 @@ const assignees = [ ...@@ -57,6 +86,17 @@ const assignees = [
}, },
]; ];
export const mockMilestones = [
{
id: 'gid://gitlab/Milestone/1',
title: 'Milestone 1',
},
{
id: 'gid://gitlab/Milestone/2',
title: 'Milestone 2',
},
];
const labels = [ const labels = [
{ {
id: 'gid://gitlab/GroupLabel/5', id: 'gid://gitlab/GroupLabel/5',
......
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vuex from 'vuex';
import { GroupByParamType } from 'ee/boards/constants'; import { GroupByParamType } from 'ee/boards/constants';
import actions, { gqlClient } from 'ee/boards/stores/actions'; import actions, { gqlClient } from 'ee/boards/stores/actions';
import boardsStoreEE from 'ee/boards/stores/boards_store_ee'; import boardsStoreEE from 'ee/boards/stores/boards_store_ee';
import * as types from 'ee/boards/stores/mutation_types'; import * as types from 'ee/boards/stores/mutation_types';
import mutations from 'ee/boards/stores/mutations';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { formatListIssues, formatBoardLists } from '~/boards/boards_util'; import { formatListIssues, formatBoardLists } from '~/boards/boards_util';
...@@ -11,7 +14,9 @@ import { issuableTypes } from '~/boards/constants'; ...@@ -11,7 +14,9 @@ import { issuableTypes } from '~/boards/constants';
import * as typesCE from '~/boards/stores/mutation_types'; import * as typesCE from '~/boards/stores/mutation_types';
import * as commonUtils from '~/lib/utils/common_utils'; import * as commonUtils from '~/lib/utils/common_utils';
import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { mockLists, mockIssue, mockIssue2, mockEpic, rawIssue } from '../mock_data'; import { mockLists, mockIssue, mockIssue2, mockEpic, rawIssue, mockMilestones } from '../mock_data';
Vue.use(Vuex);
const expectNotImplemented = (action) => { const expectNotImplemented = (action) => {
it('is not implemented', () => { it('is not implemented', () => {
...@@ -1059,3 +1064,84 @@ describe('moveIssue', () => { ...@@ -1059,3 +1064,84 @@ describe('moveIssue', () => {
}); });
}); });
}); });
describe('fetchMilestones', () => {
const queryResponse = {
data: {
project: {
milestones: {
nodes: mockMilestones,
},
},
},
};
const queryErrors = {
data: {
project: {
errors: ['You cannot view these milestones'],
milestones: {},
},
},
};
function createStore({
state = {
boardType: 'project',
fullPath: 'gitlab-org/gitlab',
milestones: [],
milestonesLoading: false,
},
} = {}) {
return new Vuex.Store({
state,
mutations,
});
}
it('throws error if state.boardType is not group or project', () => {
const store = createStore({
state: {
boardType: 'invalid',
},
});
expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type'));
});
it('sets milestonesLoading to true', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(true);
});
describe('success', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.milestones).toBe(mockMilestones);
});
});
describe('failure', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchMilestones(store)).rejects.toThrow();
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.error).toBe('Failed to load milestones.');
});
});
});
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
mockIssuesByListId, mockIssuesByListId,
mockEpics, mockEpics,
issues, issues,
mockLists,
} from '../mock_data'; } from '../mock_data';
describe('EE Boards Store Getters', () => { describe('EE Boards Store Getters', () => {
...@@ -90,4 +91,35 @@ describe('EE Boards Store Getters', () => { ...@@ -90,4 +91,35 @@ describe('EE Boards Store Getters', () => {
).toEqual([mockIssue3, mockIssue4]); ).toEqual([mockIssue3, mockIssue4]);
}); });
}); });
describe('getListByTypeId', () => {
const [, labelList, assigneeList, milestoneList] = mockLists;
it('returns label list by labelId', () => {
const labelId = labelList.label.id;
expect(getters.getListByTypeId({ boardLists: mockLists })({ labelId })).toEqual(labelList);
});
it('returns assignee list by assigneeId', () => {
const assigneeId = assigneeList.assignee.id;
expect(getters.getListByTypeId({ boardLists: mockLists })({ assigneeId })).toEqual(
assigneeList,
);
});
it('returns milestone list by milestoneId', () => {
const milestoneId = milestoneList.milestone.id;
expect(getters.getListByTypeId({ boardLists: mockLists })({ milestoneId })).toEqual(
milestoneList,
);
});
it('returns nothing if not results', () => {
expect(
getters.getListByTypeId({ boardLists: mockLists })({ labelId: 'not found' }),
).toBeUndefined();
});
});
}); });
...@@ -1345,6 +1345,9 @@ msgstr "" ...@@ -1345,6 +1345,9 @@ msgstr ""
msgid "A merge request hasn't yet been merged" msgid "A merge request hasn't yet been merged"
msgstr "" msgstr ""
msgid "A milestone list displays issues in the selected milestone."
msgstr ""
msgid "A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details" msgid "A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details"
msgstr "" msgstr ""
...@@ -1926,6 +1929,9 @@ msgstr "" ...@@ -1926,6 +1929,9 @@ msgstr ""
msgid "Add to Slack" msgid "Add to Slack"
msgstr "" msgstr ""
msgid "Add to board"
msgstr ""
msgid "Add to epic" msgid "Add to epic"
msgstr "" msgstr ""
...@@ -12590,6 +12596,9 @@ msgstr "" ...@@ -12590,6 +12596,9 @@ msgstr ""
msgid "Failed to load labels. Please try again." msgid "Failed to load labels. Please try again."
msgstr "" msgstr ""
msgid "Failed to load milestones."
msgstr ""
msgid "Failed to load milestones. Please try again." msgid "Failed to load milestones. Please try again."
msgstr "" msgstr ""
...@@ -18179,6 +18188,9 @@ msgstr "" ...@@ -18179,6 +18188,9 @@ msgstr ""
msgid "List the merge requests that must be merged before this one." msgid "List the merge requests that must be merged before this one."
msgstr "" msgstr ""
msgid "List type"
msgstr ""
msgid "List view" msgid "List view"
msgstr "" msgstr ""
......
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