Commit 9828565d authored by Simon Knox's avatar Simon Knox

Add assignee to new board list form

Lot of vuex duplication that can be trimmed down later
parent 7a19b738
<script>
import {
GlAvatarLabeled,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
......@@ -19,18 +20,23 @@ export default {
i18n: {
listType: __('List type'),
labelListDescription: __('A label list displays issues with the selected label.'),
assigneeListDescription: __('An assignee list displays issues assigned to the selected user'),
milestoneListDescription: __('A milestone list displays issues in the selected milestone.'),
selectLabel: __('Select label'),
selectAssignee: __('Select assignee'),
selectMilestone: __('Select milestone'),
searchLabels: __('Search labels'),
searchAssignees: __('Search assignees'),
searchMilestones: __('Search milestones'),
},
columnTypes: [
{ value: ListType.label, text: __('Label') },
{ value: ListType.assignee, text: __('Assignee') },
{ value: ListType.milestone, text: __('Milestone') },
],
components: {
BoardAddNewColumnForm,
GlAvatarLabeled,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
......@@ -48,13 +54,23 @@ export default {
};
},
computed: {
...mapState(['labels', 'labelsLoading', 'milestones', 'milestonesLoading']),
...mapState([
'labels',
'labelsLoading',
'assignees',
'assigneesLoading',
'milestones',
'milestonesLoading',
]),
...mapGetters(['getListByTypeId', 'shouldUseGraphQL', 'isEpicBoard']),
items() {
if (this.labelTypeSelected) {
return this.labels;
}
if (this.assigneeTypeSelected) {
return this.assignees;
}
if (this.milestoneTypeSelected) {
return this.milestones;
}
......@@ -64,6 +80,9 @@ export default {
labelTypeSelected() {
return this.columnType === ListType.label;
},
assigneeTypeSelected() {
return this.columnType === ListType.assignee;
},
milestoneTypeSelected() {
return this.columnType === ListType.milestone;
},
......@@ -74,6 +93,12 @@ export default {
}
return this.labels.find(({ id }) => id === this.selectedId);
},
selectedAssignee() {
if (!this.assigneeTypeSelected) {
return null;
}
return this.assignees.find(({ id }) => id === this.selectedId);
},
selectedMilestone() {
if (!this.milestoneTypeSelected) {
return null;
......@@ -87,6 +112,9 @@ export default {
if (this.labelTypeSelected) {
return this.selectedLabel;
}
if (this.assigneeTypeSelected) {
return this.selectedAssignee;
}
if (this.milestoneTypeSelected) {
return this.selectedMilestone;
}
......@@ -108,6 +136,9 @@ export default {
if (this.columnType === ListType.label) {
return this.labelsLoading;
}
if (this.assigneeTypeSelected) {
return this.assigneesLoading;
}
if (this.columnType === ListType.milestone) {
return this.milestonesLoading;
}
......@@ -119,6 +150,10 @@ export default {
return this.$options.i18n.labelListDescription;
}
if (this.assigneeTypeSelected) {
return this.$options.i18n.assigneeListDescription;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.milestoneListDescription;
}
......@@ -131,6 +166,10 @@ export default {
return this.$options.i18n.selectLabel;
}
if (this.assigneeTypeSelected) {
return this.$options.i18n.selectAssignee;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.selectMilestone;
}
......@@ -143,6 +182,10 @@ export default {
return this.$options.i18n.searchLabels;
}
if (this.assigneeTypeSelected) {
return this.$options.i18n.searchAssignees;
}
if (this.milestoneTypeSelected) {
return this.$options.i18n.searchMilestones;
}
......@@ -159,6 +202,7 @@ export default {
'fetchLabels',
'highlightList',
'setAddColumnFormVisibility',
'fetchAssignees',
'fetchMilestones',
]),
highlight(listId) {
......@@ -206,6 +250,11 @@ export default {
...this.selectedMilestone,
id: getIdFromGraphQLId(this.selectedMilestone.id),
};
} else if (this.assigneeTypeSelected) {
listObj.assignee = {
...this.selectedAssignee,
id: getIdFromGraphQLId(this.selectedAssignee.id),
};
}
boardsStore.new(listObj);
......@@ -217,6 +266,9 @@ export default {
case ListType.milestone:
this.fetchMilestones(searchTerm);
break;
case ListType.assignee:
this.fetchAssignees(searchTerm);
break;
case ListType.label:
default:
this.fetchLabels(searchTerm);
......@@ -227,7 +279,8 @@ export default {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
setColumnType() {
setColumnType(type) {
this.columnType = type;
this.selectedId = null;
this.filterItems();
},
......@@ -287,7 +340,7 @@ export default {
: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" />
<gl-form-radio :value="item.id" class="gl-mb-0 gl-align-self-center" />
<span
v-if="labelTypeSelected"
class="dropdown-label-box gl-top-0"
......@@ -295,7 +348,15 @@ export default {
backgroundColor: item.color,
}"
></span>
<span>{{ item.title }}</span>
<gl-avatar-labeled
v-if="assigneeTypeSelected"
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl"
/>
<span v-else>{{ item.title }}</span>
</label>
</gl-form-radio-group>
</template>
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
query GroupBoardAssignees($fullPath: ID!, $search: String) {
workspace: group(fullPath: $fullPath) {
__typename
assignees: groupMembers(search: $search) {
__typename
nodes {
id
user {
...User
}
}
}
}
}
#import "~/graphql_shared/fragments/user.fragment.graphql"
query ProjectBoardAssignees($fullPath: ID!, $search: String) {
workspace: project(fullPath: $fullPath) {
__typename
assignees: projectMembers(search: $search) {
__typename
nodes {
id
user {
...User
}
}
}
}
}
......@@ -34,12 +34,14 @@ import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutat
import epicBoardListsQuery from '../graphql/epic_board_lists.query.graphql';
import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardAssigneesQuery from '../graphql/group_board_assignees.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
import issueSetEpicMutation from '../graphql/issue_set_epic.mutation.graphql';
import issueSetWeightMutation from '../graphql/issue_set_weight.mutation.graphql';
import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql';
import listsEpicsQuery from '../graphql/lists_epics.query.graphql';
import projectBoardAssigneesQuery from '../graphql/project_board_assignees.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql';
......@@ -646,6 +648,52 @@ export default {
});
},
fetchAssignees({ state, commit }, search) {
commit(types.RECEIVE_ASSIGNEES_REQUEST);
const { fullPath, boardType } = state;
const variables = {
fullPath,
search,
};
let query;
if (boardType === BoardType.project) {
query = projectBoardAssigneesQuery;
}
if (boardType === BoardType.group) {
query = groupBoardAssigneesQuery;
}
if (!query) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unknown board type');
}
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const [firstError] = data.workspace.errors || [];
const assignees = data.workspace.assignees.nodes;
if (firstError) {
throw new Error(firstError);
}
commit(
types.RECEIVE_ASSIGNEES_SUCCESS,
assignees.map(({ user }) => user),
);
})
.catch((e) => {
commit(types.RECEIVE_ASSIGNEES_FAILURE);
throw e;
});
},
createList: ({ getters, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
if (!getters.isEpicBoard) {
dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId });
......
......@@ -38,3 +38,6 @@ 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';
export const RECEIVE_ASSIGNEES_REQUEST = 'RECEIVE_ASSIGNEES_REQUEST';
export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
export const RECEIVE_ASSIGNEES_FAILURE = 'RECEIVE_ASSIGNEES_FAILURE';
......@@ -232,4 +232,18 @@ export default {
state.milestonesLoading = false;
state.error = __('Failed to load milestones.');
},
[mutationTypes.RECEIVE_ASSIGNEES_REQUEST](state) {
state.assigneesLoading = true;
},
[mutationTypes.RECEIVE_ASSIGNEES_SUCCESS](state, assignees) {
state.assignees = assignees;
state.assigneesLoading = false;
},
[mutationTypes.RECEIVE_ASSIGNEES_FAILURE](state) {
state.assigneesLoading = false;
state.error = __('Failed to load assignees.');
},
};
......@@ -14,4 +14,6 @@ export default () => ({
epicsFlags: {},
milestones: [],
milestonesLoading: false,
assignees: [],
assigneesLoading: false,
});
......@@ -15,7 +15,8 @@ RSpec.describe 'User adds milestone lists', :js do
let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) }
let_it_be(:issue) { create(:issue, project: project, milestone: milestone) }
let_it_be(:issue_with_milestone) { create(:issue, project: project, milestone: milestone) }
let_it_be(:issue_with_assignee) { create(:issue, project: project, assignees: [user]) }
before_all do
project.add_maintainer(user)
......@@ -31,7 +32,10 @@ RSpec.describe 'User adds milestone lists', :js do
with_them do
before do
stub_licensed_features(board_milestone_lists: true)
stub_licensed_features(
board_milestone_lists: true,
board_assignee_lists: true
)
sign_in(user)
set_cookie('sidebar_collapsed', 'true')
......@@ -51,28 +55,31 @@ RSpec.describe 'User adds milestone lists', :js do
end
it 'creates milestone column' do
click_button button_text
wait_for_all_requests
select('Milestone', from: 'List type')
add_list('Milestone', milestone.title)
add_milestone_list(milestone)
expect(page).to have_selector('.board', text: milestone.title)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_milestone.title)
end
wait_for_all_requests
it 'creates assignee column' do
add_list('Assignee', user.name)
expect(page).to have_selector('.board', text: milestone.title)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title)
expect(page).to have_selector('.board', text: user.name)
expect(find('.board:nth-child(2) .board-card')).to have_content(issue_with_assignee.title)
end
end
def add_milestone_list(milestone)
def add_list(list_type, title)
click_button 'Create list'
wait_for_all_requests
select(list_type, from: 'List type')
page.within('.board-add-new-list') do
find('label', text: milestone.title).click
find('label', text: title).click
click_button 'Add'
end
end
def button_text
'Create list'
wait_for_all_requests
end
end
import { GlSearchBoxByType } from '@gitlab/ui';
import { GlAvatarLabeled, GlSearchBoxByType, GlFormSelect } 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 { ListType } from '~/boards/constants';
import defaultState from '~/boards/stores/state';
import { mockLists } from '../mock_data';
import { mockAssignees, mockLists } from '../mock_data';
const mockLabelList = mockLists[1];
Vue.use(Vuex);
describe('Board card layout', () => {
describe('BoardAddNewColumn', () => {
let wrapper;
let shouldUseGraphQL;
......@@ -30,6 +31,7 @@ describe('Board card layout', () => {
const mountComponent = ({
selectedId,
labels = [],
assignees = [],
getListByTypeId = jest.fn(),
actions = {},
} = {}) => {
......@@ -57,6 +59,8 @@ describe('Board card layout', () => {
state: {
labels,
labelsLoading: false,
assignees,
assigneesLoading: false,
},
}),
provide: {
......@@ -71,10 +75,12 @@ describe('Board card layout', () => {
wrapper = null;
});
const findForm = () => wrapper.findComponent(BoardAddNewColumnForm);
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');
const listTypeSelect = () => wrapper.findComponent(GlFormSelect);
beforeEach(() => {
shouldUseGraphQL = true;
......@@ -152,4 +158,39 @@ describe('Board card layout', () => {
expect(createList).not.toHaveBeenCalled();
});
});
describe('assignee list', () => {
beforeEach(async () => {
mountComponent({
assignees: mockAssignees,
actions: {
fetchAssignees: jest.fn(),
},
});
listTypeSelect().vm.$emit('change', ListType.assignee);
await nextTick();
});
it('sets assignee placeholder text in form', async () => {
expect(findForm().props()).toMatchObject({
formDescription: BoardAddNewColumn.i18n.assigneeListDescription,
searchLabel: BoardAddNewColumn.i18n.selectAssignee,
searchPlaceholder: BoardAddNewColumn.i18n.searchAssignees,
});
});
it('shows list of assignees', () => {
const userList = wrapper.findAllComponents(GlAvatarLabeled);
const [firstUser] = mockAssignees;
expect(userList).toHaveLength(mockAssignees.length);
expect(userList.at(0).props()).toMatchObject({
label: firstUser.name,
subLabel: firstUser.username,
});
});
});
});
......@@ -76,7 +76,7 @@ const defaultDescendantCounts = {
closedIssues: 0,
};
const assignees = [
export const mockAssignees = [
{
id: 'gid://gitlab/User/2',
username: 'angelina.herman',
......@@ -84,6 +84,13 @@ const assignees = [
avatar: 'https://www.gravatar.com/avatar/eb7b664b13a30ad9f9ba4b61d7075470?s=80&d=identicon',
webUrl: 'http://127.0.0.1:3000/angelina.herman',
},
{
id: 'gid://gitlab/User/118',
username: 'jacklyn.moore',
name: 'Brock Jaskolski',
avatar: 'https://www.gravatar.com/avatar/af29c072d9fcf315772cfd802c7a7d35?s=80&d=identicon',
webUrl: 'http://127.0.0.1:3000/jacklyn.moore',
},
];
export const mockMilestones = [
......@@ -127,7 +134,7 @@ export const rawIssue = {
],
},
assignees: {
nodes: assignees,
nodes: mockAssignees,
},
epic: {
id: 'gid://gitlab/Epic/41',
......@@ -147,7 +154,7 @@ export const mockIssue = {
weight: null,
confidential: false,
path: `/${mockIssueProjectPath}/-/issues/27`,
assignees,
assignees: mockAssignees,
labels,
epic: {
id: 'gid://gitlab/Epic/41',
......@@ -165,7 +172,7 @@ export const mockIssue2 = {
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
assignees: mockAssignees,
labels,
epic: {
id: 'gid://gitlab/Epic/40',
......@@ -183,7 +190,7 @@ export const mockIssue3 = {
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
assignees: mockAssignees,
labels,
epic: null,
};
......@@ -198,7 +205,7 @@ export const mockIssue4 = {
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
assignees: mockAssignees,
labels,
epic: null,
};
......
......@@ -19,9 +19,10 @@ import {
mockIssue,
mockIssue2,
mockEpic,
mockEpics,
rawIssue,
mockMilestones,
mockAssignees,
mockEpics,
} from '../mock_data';
Vue.use(Vuex);
......@@ -1268,3 +1269,84 @@ describe('fetchMilestones', () => {
});
});
});
describe('fetchAssignees', () => {
const queryResponse = {
data: {
workspace: {
assignees: {
nodes: mockAssignees.map((assignee) => ({ user: assignee })),
},
},
},
};
const queryErrors = {
data: {
project: {
errors: ['You cannot view these assignees'],
assignees: {},
},
},
};
function createStore({
state = {
boardType: 'project',
fullPath: 'gitlab-org/gitlab',
assignees: [],
assigneesLoading: 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.fetchAssignees(store)).toThrow(new Error('Unknown board type'));
});
it('sets assigneesLoading to true', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchAssignees(store);
expect(store.state.assigneesLoading).toBe(true);
});
describe('success', () => {
it('sets state.assignees from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchAssignees(store);
expect(store.state.assigneesLoading).toBe(false);
expect(store.state.assignees).toEqual(expect.objectContaining(mockAssignees));
});
});
describe('failure', () => {
it('throws an error and displays an error message', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchAssignees(store)).rejects.toThrow();
expect(store.state.assigneesLoading).toBe(false);
expect(store.state.error).toBe('Failed to load assignees.');
});
});
});
......@@ -3224,6 +3224,9 @@ msgstr ""
msgid "An application called %{link_to_client} is requesting access to your GitLab account."
msgstr ""
msgid "An assignee list displays issues assigned to the selected user"
msgstr ""
msgid "An email notification was recently sent from the admin panel. Please wait %{wait_time_in_words} before attempting to send another message."
msgstr ""
......@@ -12629,6 +12632,9 @@ msgstr ""
msgid "Failed to install."
msgstr ""
msgid "Failed to load assignees."
msgstr ""
msgid "Failed to load assignees. Please try again."
msgstr ""
......@@ -26413,6 +26419,9 @@ msgstr ""
msgid "Search an environment spec"
msgstr ""
msgid "Search assignees"
msgstr ""
msgid "Search authors"
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