Commit 217004d7 authored by Florie Guibert's avatar Florie Guibert

Swimlanes - Filtering for issues

Add support for filtering by epic
parent 99fb4cbd
......@@ -96,10 +96,11 @@ export default {
showAssigneeListDetails() {
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
},
issuesCount() {
return this.list.issuesSize;
},
issuesTooltipLabel() {
const { issuesSize } = this.list;
return n__(`%d issue`, `%d issues`, issuesSize);
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
......@@ -299,7 +300,7 @@ export default {
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
<!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">
......
......@@ -161,7 +161,12 @@ export default () => {
}
},
methods: {
...mapActions(['setInitialBoardData', 'setFilters', 'fetchEpicsSwimlanes']),
...mapActions([
'setInitialBoardData',
'setFilters',
'fetchEpicsSwimlanes',
'fetchIssuesForAllLists',
]),
updateTokens() {
this.filterManager.updateTokens();
},
......@@ -169,6 +174,7 @@ export default () => {
this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
this.fetchEpicsSwimlanes(false);
this.fetchIssuesForAllLists();
}
},
updateDetailIssue(newIssue, multiSelect = false) {
......
#import "./issue.fragment.graphql"
query GroupListIssues($fullPath: ID!, $boardId: ID!) {
group(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
id
issues {
nodes {
...IssueNode
}
}
}
}
}
}
}
#import "./issue.fragment.graphql"
query ListIssues(
$fullPath: ID!
$boardId: ID!
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists {
nodes {
id
issues(filters: $filters) {
nodes {
...IssueNode
}
}
}
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
board(id: $boardId) {
lists {
nodes {
id
issues(filters: $filters) {
nodes {
...IssueNode
}
}
}
}
}
}
}
#import "./issue.fragment.graphql"
query ProjectListIssues($fullPath: ID!, $boardId: ID!) {
project(fullPath: $fullPath) {
board(id: $boardId) {
lists {
nodes {
id
issues {
nodes {
...IssueNode
}
}
}
}
}
}
}
import Cookies from 'js-cookie';
import { sortBy } from 'lodash';
import { sortBy, pick } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
......@@ -10,8 +10,7 @@ import * as types from './mutation_types';
import { formatListIssues, fullBoardId } from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
import groupListsIssuesQuery from '../queries/group_lists_issues.query.graphql';
import projectListsIssuesQuery from '../queries/project_lists_issues.query.graphql';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
import projectBoardQuery from '../queries/project_board.query.graphql';
import groupBoardQuery from '../queries/group_board.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
......@@ -38,7 +37,14 @@ export default {
},
setFilters: ({ commit }, filters) => {
const { scope, utf8, state, ...filterParams } = filters;
const filterParams = pick(filters, [
'assigneeUsername',
'authorUsername',
'labelName',
'milestoneTitle',
'releaseTag',
'search',
]);
commit(types.SET_FILTERS, filterParams);
},
......@@ -197,19 +203,20 @@ export default {
fetchIssuesForAllLists: ({ state, commit }) => {
commit(types.REQUEST_ISSUES_FOR_ALL_LISTS);
const { endpoints, boardType } = state;
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
const query = boardType === BoardType.group ? groupListsIssuesQuery : projectListsIssuesQuery;
const variables = {
fullPath,
boardId: fullBoardId(boardId),
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
};
return gqlClient
.query({
query,
query: listsIssuesQuery,
variables,
})
.then(({ data }) => {
......
......@@ -2,6 +2,11 @@ export function getMilestone({ milestone }) {
return milestone || null;
}
export function fullEpicId(epicId) {
return `gid://gitlab/Epic/${epicId}`;
}
export default {
getMilestone,
fullEpicId,
};
<script>
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import BoardListHeaderFoss from '~/boards/components/board_list_header.vue';
import { __, sprintf, s__ } from '~/locale';
import { __, sprintf, s__, n__ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
import { inactiveId, LIST } from '~/boards/constants';
import eventHub from '~/sidebar/event_hub';
......@@ -14,13 +14,21 @@ export default {
};
},
computed: {
...mapState(['activeId']),
...mapState(['activeId', 'issuesByListId']),
...mapGetters(['isSwimlanesOn']),
issuesCount() {
if (this.isSwimlanesOn) {
return this.issuesByListId[this.list.id] ? this.issuesByListId[this.list.id].length : 0;
}
return this.list.issuesSize;
},
issuesTooltip() {
const { issuesSize, maxIssueCount } = this.list;
const { maxIssueCount } = this.list;
if (maxIssueCount > 0) {
return sprintf(__('%{issuesSize} issues with a limit of %{maxIssueCount}'), {
issuesSize,
return sprintf(__('%{issuesCount} issues with a limit of %{maxIssueCount}'), {
issuesCount: this.issuesCount,
maxIssueCount,
});
}
......@@ -28,6 +36,9 @@ export default {
// TODO: Remove this pattern.
return BoardListHeaderFoss.computed.issuesTooltip.call(this);
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
weightCountToolTip() {
const { totalWeight } = this.list;
......
......@@ -134,7 +134,7 @@ export default {
:disabled="disabled"
:root-path="rootPath"
/>
<div class="board-lane-unassigned-issues gl-sticky gl-display-inline-block gl-left-0">
<div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0">
<div class="gl-left-0 gl-py-5 gl-px-3 gl-display-flex gl-align-items-center">
<span
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
......@@ -154,7 +154,7 @@ export default {
</span>
</div>
</div>
<div class="gl-display-flex">
<div class="gl-display-flex" data-testid="board-lane-unassigned-issues">
<issues-lane-list
v-for="list in lists"
:key="`${list.id}-issues`"
......
export const DRAGGABLE_TAG = 'div';
/* eslint-disable @gitlab/require-i18n-strings */
export const EpicFilterType = {
any: 'Any',
none: 'None',
};
export default {
DRAGGABLE_TAG,
EpicFilterType,
};
import { sortBy } from 'lodash';
import { sortBy, pick } from 'lodash';
import Cookies from 'js-cookie';
import axios from '~/lib/utils/axios_utils';
import boardsStore from '~/boards/stores/boards_store';
......@@ -6,11 +6,13 @@ import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import actionsCE from '~/boards/stores/actions';
import { BoardType, ListType } from '~/boards/constants';
import { EpicFilterType } from '../constants';
import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types';
import { fullEpicId } from '../boards_util';
import createDefaultClient from '~/lib/graphql';
import groupEpicsSwimlanesQuery from '../queries/group_epics_swimlanes.query.graphql';
import epicsSwimlanesQuery from '../queries/epics_swimlanes.query.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -22,6 +24,27 @@ const gqlClient = createDefaultClient();
export default {
...actionsCE,
setFilters: ({ commit }, filters) => {
const filterParams = pick(filters, [
'assigneeUsername',
'authorUsername',
'epicId',
'labelName',
'milestoneTitle',
'releaseTag',
'search',
'weight',
]);
if (filterParams.epicId === EpicFilterType.any || filterParams.epicId === EpicFilterType.none) {
filterParams.epicWildcardId = filterParams.epicId.toUpperCase();
filterParams.epicId = undefined;
} else if (filterParams.epicId) {
filterParams.epicId = fullEpicId(filterParams.epicId);
}
commit(types.SET_FILTERS, filterParams);
},
fetchEpicsSwimlanes({ state, commit }, withLists = true) {
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
......@@ -37,7 +60,7 @@ export default {
return gqlClient
.query({
query: groupEpicsSwimlanesQuery,
query: epicsSwimlanesQuery,
variables,
})
.then(({ data }) => {
......
......@@ -16,3 +16,4 @@ export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const SET_FILTERS = 'SET_FILTERS';
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'epics swimlanes filtering', :js do
let_it_be(:user) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:planning) { create(:label, project: project, name: 'Planning', description: 'Test') }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
let_it_be(:backlog) { create(:label, project: project, name: 'Backlog') }
let_it_be(:closed) { create(:label, project: project, name: 'Closed') }
let_it_be(:list1) { create(:list, board: board, label: planning, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: development, position: 1) }
let_it_be(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
let_it_be(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) }
let_it_be(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) }
let_it_be(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning, testing], relative_position: 6) }
let_it_be(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) }
let_it_be(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) }
let_it_be(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) }
let_it_be(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
let_it_be(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
context 'filtering' do
before do
project.add_maintainer(user)
project.add_maintainer(user2)
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 200)
end
it 'filters by author' do
set_filter("author", user2.username)
click_filter_link(user2.username)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by assignee' do
set_filter("assignee", user.username)
click_filter_link(user.username)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
it 'filters by milestone' do
set_filter("milestone", "\"#{milestone.title}")
click_filter_link(milestone.title)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_board_cards(3, 0)
wait_for_board_cards(4, 0)
end
it 'filters by label' do
set_filter("label", testing.title)
click_filter_link(testing.title)
submit_filter
wait_for_requests
wait_for_board_cards(2, 1)
wait_for_empty_boards((3..4))
end
end
def visit_board_page
visit project_boards_path(project)
wait_for_requests
end
def wait_for_board_cards(board_number, expected_cards)
page.within(find(".board-swimlanes-headers .board:nth-child(#{board_number})")) do
expect(page.find('.board-header')).to have_content(expected_cards.to_s)
end
page.within(find("[data-testid='board-lane-unassigned-issues'] .board:nth-child(#{board_number})")) do
expect(page).to have_selector('.board-card', count: expected_cards)
end
end
def wait_for_empty_boards(board_numbers)
board_numbers.each do |board|
wait_for_board_cards(board, 0)
end
end
def set_filter(type, text)
find('.filtered-search').native.send_keys("#{type}:=#{text}")
end
def submit_filter
find('.filtered-search').native.send_keys(:enter)
end
def click_filter_link(link_text)
page.within('.filtered-search-box') do
expect(page).to have_button(link_text)
click_button(link_text)
end
end
end
......@@ -22,29 +22,27 @@ RSpec.describe 'epics swimlanes', :js do
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
context 'switch to swimlanes view' do
context 'feature flag on' do
before do
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
before do
stub_licensed_features(epics: true)
sign_in(user)
visit_board_page
page.within('.board-swimlanes-toggle-wrapper') do
page.find('.dropdown-toggle').click
page.find('.dropdown-item', text: 'Epic').click
end
end
it 'displays epics swimlanes when selecting Epic in Group by dropdown' do
expect(page).to have_css('.board-swimlanes')
it 'displays epics swimlanes when selecting Epic in Group by dropdown' do
expect(page).to have_css('.board-swimlanes')
epic_lanes = page.all(:css, '.board-epic-lane')
expect(epic_lanes.length).to eq(2)
end
epic_lanes = page.all(:css, '.board-epic-lane')
expect(epic_lanes.length).to eq(2)
end
it 'displays issue not assigned to epic in unassigned issues lane' do
page.within('.board-lane-unassigned-issues') do
expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1')
end
it 'displays issue not assigned to epic in unassigned issues lane' do
page.within('.board-lane-unassigned-issues-title') do
expect(page.find('span[data-testid="issues-lane-issue-count"]')).to have_content('1')
end
end
end
......
......@@ -6,6 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import BoardListHeader from 'ee/boards/components/board_list_header.vue';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
import getters from 'ee/boards/stores/getters';
import List from '~/boards/models/list';
import { ListType, inactiveId } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils';
......@@ -28,7 +29,7 @@ describe('Board List Header Component', () => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
store = new Vuex.Store({ state: { activeId: inactiveId } });
store = new Vuex.Store({ state: { activeId: inactiveId }, getters });
jest.spyOn(store, 'dispatch').mockImplementation();
});
......
......@@ -13,6 +13,44 @@ const expectNotImplemented = action => {
});
};
describe('setFilters', () => {
it('should commit mutation SET_FILTERS, updates epicId with global id', done => {
const state = {
filters: {},
};
const filters = { labelName: 'label', epicId: 1 };
const updatedFilters = { labelName: 'label', epicId: 'gid://gitlab/Epic/1' };
testAction(
actions.setFilters,
filters,
state,
[{ type: types.SET_FILTERS, payload: updatedFilters }],
[],
done,
);
});
it('should commit mutation SET_FILTERS, updates epicWildcardId', done => {
const state = {
filters: {},
};
const filters = { labelName: 'label', epicId: 'None' };
const updatedFilters = { labelName: 'label', epicWildcardId: 'NONE' };
testAction(
actions.setFilters,
filters,
state,
[{ type: types.SET_FILTERS, payload: updatedFilters }],
[],
done,
);
});
});
describe('setShowLabels', () => {
it('should commit mutation SET_SHOW_LABELS', done => {
const state = {
......
......@@ -452,7 +452,7 @@ msgstr ""
msgid "%{issuableType} will be removed! Are you sure?"
msgstr ""
msgid "%{issuesSize} issues with a limit of %{maxIssueCount}"
msgid "%{issuesCount} issues with a limit of %{maxIssueCount}"
msgstr ""
msgid "%{issuesSize} with a limit of %{maxIssueCount}"
......
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