Commit 6bd0d56c authored by Heinrich Lee Yu's avatar Heinrich Lee Yu Committed by Mayra Cabrera

Support iteration filters in issues API

This is required for vue_issuables_list because it uses the API to query
the issues
parent 6f94a527
......@@ -22,7 +22,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
ajaxFilterConfig() {
return {
endpoint: `${gon.relative_url_root || ''}${this.endpoint}`,
endpoint: this.endpoint,
searchKey: 'search',
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
......@@ -33,9 +33,11 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
}
itemClicked(e) {
super.itemClicked(e, selected =>
selected.querySelector('.dropdown-light-content').innerText.trim(),
);
super.itemClicked(e, selected => {
const title = selected.querySelector('.dropdown-light-content').innerText.trim();
return DropdownUtils.getEscapedText(title);
});
}
renderContent(forceShowList = false) {
......
......@@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
super({
...options,
endpoint: '/-/autocomplete/users.json',
endpoint: `${gon.relative_url_root || ''}/-/autocomplete/users.json`,
symbol: '@',
});
}
......
......@@ -12,6 +12,7 @@ export default class FilteredSearchDropdownManager {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
iterationsEndpoint = '',
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
......@@ -28,6 +29,7 @@ export default class FilteredSearchDropdownManager {
this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint);
this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.iterationsEndpoint = removeTrailingSlash(iterationsEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint);
......
......@@ -52,16 +52,24 @@ export default class FilteredSearchManager {
this.placeholder = placeholder;
this.anchor = anchor;
const { multipleAssignees } = this.filteredSearchInput.dataset;
const {
multipleAssignees,
epicsEndpoint,
iterationsEndpoint,
} = this.filteredSearchInput.dataset;
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
this.filteredSearchTokenKeys.enableMultipleAssignees();
}
const { epicsEndpoint } = this.filteredSearchInput.dataset;
if (!epicsEndpoint && this.filteredSearchTokenKeys.removeEpicToken) {
this.filteredSearchTokenKeys.removeEpicToken();
}
if (!iterationsEndpoint && this.filteredSearchTokenKeys.removeIterationToken) {
this.filteredSearchTokenKeys.removeIterationToken();
}
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
......@@ -112,6 +120,7 @@ export default class FilteredSearchManager {
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
iterationsEndpoint = '',
} = this.filteredSearchInput.dataset;
this.dropdownManager = new FilteredSearchDropdownManager({
......@@ -121,6 +130,7 @@ export default class FilteredSearchManager {
releasesEndpoint,
environmentsEndpoint,
epicsEndpoint,
iterationsEndpoint,
tokenizer: this.tokenizer,
page: this.page,
isGroup: this.isGroup,
......
......@@ -96,6 +96,7 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_iteration', type: type
#js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
......
......@@ -60,6 +60,9 @@ GET /issues?state=opened
| `due_date` | string | no | Return issues that have no due date (`0`) or whose due date is this week, this month, between two weeks ago and next month, or which are overdue. Accepts: `0` (no due date), `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. _(Introduced in [GitLab 13.3](https://gitlab.com/gitlab-org/gitlab/-/issues/233420))_ |
| `iids[]` | integer array | no | Return only the issues having the given `iid` |
| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` |
| `iteration_id` **(STARTER)** | integer | no | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.6)_ |
| `iteration_title` **(STARTER)** | string | no | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.6)_ |
| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. |
| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. |
| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. _([Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/14016) in GitLab 10.0)_ |
......
import DropdownUser from '~/filtered_search/dropdown_user';
import DropdownNonUser from '~/filtered_search/dropdown_non_user';
import DropdownWeight from './dropdown_weight';
import DropdownAjaxFilter from '~/filtered_search/dropdown_ajax_filter';
import AvailableDropdownMappingsCE from '~/filtered_search/available_dropdown_mappings';
export default class AvailableDropdownMappings {
......@@ -9,6 +10,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
iterationsEndpoint,
epicsEndpoint,
releasesEndpoint,
environmentsEndpoint,
......@@ -20,6 +22,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.iterationsEndpoint = iterationsEndpoint;
this.epicsEndpoint = epicsEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.environmentsEndpoint = environmentsEndpoint;
......@@ -65,6 +68,16 @@ export default class AvailableDropdownMappings {
element: this.container.querySelector('#js-dropdown-epic'),
};
ceMappings.iteration = {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.iterationsEndpoint,
symbol: '',
},
element: this.container.querySelector('#js-dropdown-iteration'),
};
return this.ceAvailableMappings.buildMappings(supportedTokens, ceMappings);
}
......
......@@ -25,6 +25,15 @@ export const epicTokenKey = {
icon: 'epic',
};
export const iterationTokenKey = {
formattedKey: __('Iteration'),
key: 'iteration',
type: 'string',
param: 'title',
symbol: '',
icon: 'iteration',
};
export const weightConditions = [
{
url: 'weight=None',
......@@ -79,15 +88,34 @@ export const epicConditions = [
},
];
export const iterationConditions = [
{
url: 'iteration_id=None',
operator: '=',
tokenKey: 'iteration',
value: __('None'),
},
{
url: 'iteration_id=Any',
operator: '=',
tokenKey: 'iteration',
value: __('Any'),
},
];
/**
* Filter tokens for issues in EE.
*/
class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
constructor() {
const milestoneTokenKeyIndex = tokenKeys.findIndex(tk => tk.key === 'milestone');
tokenKeys.splice(milestoneTokenKeyIndex + 1, 0, iterationTokenKey);
super([...tokenKeys, epicTokenKey, weightTokenKey], alternativeTokenKeys, [
...conditions,
...weightConditions,
...epicConditions,
...iterationConditions,
]);
}
......@@ -105,7 +133,15 @@ class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
}
removeEpicToken() {
const index = this.tokenKeys.findIndex(token => token.key === epicTokenKey.key);
this.removeToken(epicTokenKey);
}
removeIterationToken() {
this.removeToken(iterationTokenKey);
}
removeToken(tokenKey) {
const index = this.tokenKeys.findIndex(token => token.key === tokenKey.key);
if (index >= 0) {
this.tokenKeys.splice(index, 1);
}
......
......@@ -11,7 +11,12 @@ module EE
override :scalar_params
def scalar_params
@scalar_params ||= super + [:weight, :epic_id, :include_subepics]
@scalar_params ||= super + [:weight, :epic_id, :include_subepics, :iteration_id, :iteration_title]
end
override :negatable_params
def negatable_params
@negatable_params ||= super + [:iteration_title]
end
end
......@@ -64,21 +69,23 @@ module EE
end
def by_iteration(items)
return items unless params.iterations
return items unless params.by_iteration?
case params.iterations.to_s.downcase
when ::IssuableFinder::Params::FILTER_NONE
if params.filter_by_no_iteration?
items.no_iteration
when ::IssuableFinder::Params::FILTER_ANY
elsif params.filter_by_any_iteration?
items.any_iteration
elsif params.filter_by_iteration_title?
items.with_iteration_title(params[:iteration_title])
else
items.in_iterations(params.iterations)
items.in_iterations(params[:iteration_id])
end
end
override :filter_negated_items
def filter_negated_items(items)
items = by_negated_epic(items)
items = by_negated_iteration(items)
super(items)
end
......@@ -88,5 +95,11 @@ module EE
items.not_in_epics(not_params[:epic_id].to_i)
end
def by_negated_iteration(items)
return items unless not_params[:iteration_title].present?
items.without_iteration_title(not_params[:iteration_title])
end
end
end
......@@ -51,8 +51,20 @@ module EE
end
end
def iterations
params[:iteration_id]
def by_iteration?
params[:iteration_id].present? || params[:iteration_title].present?
end
def filter_by_no_iteration?
params[:iteration_id].to_s.downcase == ::IssuableFinder::Params::FILTER_NONE
end
def filter_by_any_iteration?
params[:iteration_id].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY
end
def filter_by_iteration_title?
params[:iteration_title].present?
end
end
end
......
......@@ -17,6 +17,14 @@ module EE
options[:data]['epics-endpoint'] = group_epics_path(@group)
end
if allow_filtering_by_iteration?
if @project
options[:data]['iterations-endpoint'] = expose_path(api_v4_projects_iterations_path(id: @project.id))
elsif @group
options[:data]['iterations-endpoint'] = expose_path(api_v4_groups_iterations_path(id: @group.id))
end
end
options
end
......@@ -131,6 +139,16 @@ module EE
context.feature_available?(:multiple_issue_assignees))
end
def allow_filtering_by_iteration?
# We currently only have group-level iterations so we hide
# this filter for projects under personal namespaces
return false if @project && @project.namespace.user?
context = @project.presence || @group.presence
context && ::Feature.enabled?(:filter_bar_iterations, context) && context.feature_available?(:iterations)
end
def gitlab_com_snippet_db_search?
@current_user &&
@show_snippets &&
......
......@@ -32,6 +32,8 @@ module EE
scope :no_iteration, -> { where(sprint_id: nil) }
scope :any_iteration, -> { where.not(sprint_id: nil) }
scope :in_iterations, ->(iterations) { where(sprint_id: iterations) }
scope :with_iteration_title, ->(iteration_title) { joins(:iteration).where(sprints: { title: iteration_title }) }
scope :without_iteration_title, ->(iteration_title) { left_outer_joins(:iteration).where('sprints.title != ? OR sprints.id IS NULL', iteration_title) }
scope :on_status_page, -> do
joins(project: :status_page_setting)
.where(status_page_settings: { enabled: true })
......
- type = local_assigns.fetch(:type)
- return unless type == :issues || type == :boards || type == :boards_modal
#js-dropdown-iteration.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ 'data-dropdown' => true }
%li.filter-dropdown-item{ 'data-value' => 'None' }
%button.btn.btn-link
= _('None')
%li.filter-dropdown-item{ 'data-value' => 'Any' }
%button.btn.btn-link
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item
%button.btn.btn-link{ type: 'button' }
-# haml-lint:disable NoPlainNodes
%span.dropdown-light-content
{{title}}
---
title: Allow filtering by iterations in issue API
merge_request: 44690
author:
type: added
---
name: filter_bar_iterations
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44690
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/118742
milestone: '13.6'
type: development
group: group::project management
default_enabled: false
......@@ -14,6 +14,14 @@ module EE
mutually_exclusive :epic_id, :epic_iid
end
params :negatable_issue_filter_params_ee do
optional :iteration_id, types: [Integer, String], integer_none_any: true,
desc: 'Return issues which are assigned to the iteration with the given ID'
optional :iteration_title, type: String,
desc: 'Return issues which are assigned to the iteration with the given title'
mutually_exclusive :iteration_id, :iteration_title
end
params :optional_issues_params_ee do
optional :weight, types: [Integer, String], integer_none_any: true, desc: 'The weight of the issue'
optional :epic_id, types: [Integer, String], integer_none_any: true, desc: 'The ID of an epic associated with the issues'
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Filter issues by iteration', :js do
include FilteredSearchHelpers
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:iteration_1) { create(:iteration, group: group) }
let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: project, iteration: iteration_2) }
let_it_be(:no_iteration_issue) { create(:issue, project: project) }
shared_examples 'filters by iteration' do
context 'when iterations are not available' do
before do
stub_licensed_features(iterations: false)
visit page_path
end
it 'does not show the iteration filter option' do
find('.filtered-search').set('iter')
expect(find('#js-dropdown-hint')).not_to have_selector('.filter-dropdown .filter-dropdown-item', text: 'Iteration')
end
end
context 'when iterations are available' do
before do
stub_licensed_features(iterations: true)
visit page_path
end
it 'filters by iteration' do
input_filtered_search("iteration:=\"#{iteration_1.title}\"")
aggregate_failures do
expect(page).to have_content(iteration_1_issue.title)
expect(page).not_to have_content(iteration_2_issue.title)
expect(page).not_to have_content(no_iteration_issue.title)
end
end
it 'filters by negated iteration' do
page.within('.filtered-search-wrapper') do
find('.filtered-search').set('iter')
click_button('Iteration')
find('.btn-helptext', text: 'is not').click
click_button(iteration_1.title)
find('.filtered-search').send_keys(:enter)
end
aggregate_failures do
expect(page).not_to have_content(iteration_1_issue.title)
expect(page).to have_content(iteration_2_issue.title)
expect(page).to have_content(no_iteration_issue.title)
end
end
end
end
context 'project issues list' do
let(:page_path) { project_issues_path(project) }
let(:issue_title_selector) { '.issue .title' }
it_behaves_like 'filters by iteration'
context 'when vue_issuables_list is disabled' do
before do
stub_feature_flags(vue_issuables_list: false)
end
it_behaves_like 'filters by iteration'
end
end
context 'group issues list' do
let(:page_path) { issues_group_path(group) }
let(:issue_title_selector) { '.issue .title' }
it_behaves_like 'filters by iteration'
context 'when vue_issuables_list is disabled' do
before do
stub_feature_flags(vue_issuables_list: false)
end
it_behaves_like 'filters by iteration'
end
end
context 'project board' do
let_it_be(:board) { create(:board, project: project) }
let(:page_path) { project_board_path(project, board) }
let(:issue_title_selector) { '.board-card .board-card-title' }
it_behaves_like 'filters by iteration'
end
context 'group board' do
let_it_be(:board) { create(:board, group: group) }
let(:page_path) { group_board_path(group, board) }
let(:issue_title_selector) { '.board-card .board-card-title' }
before do
stub_feature_flags(graphql_board_lists: false)
end
it_behaves_like 'filters by iteration'
end
end
......@@ -170,6 +170,22 @@ RSpec.describe IssuesFinder do
end
end
context 'filter issue by iteration title' do
let(:params) { { iteration_title: iteration_1.title } }
it 'returns all issues with the iteration title' do
expect(issues).to contain_exactly(iteration_1_issue)
end
end
context 'filter issue by negated iteration title' do
let(:params) { { not: { iteration_title: iteration_1.title } } }
it 'returns all issues that do not match the iteration title' do
expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, iteration_2_issue)
end
end
context 'without iteration_id param' do
let(:params) { { iteration_id: nil } }
......
......@@ -49,6 +49,67 @@ RSpec.describe SearchHelper do
expect(options[:data][:'multiple-assignees']).to eq('true')
end
end
describe 'iterations-endpoint' do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:project_under_group, refind: true) { create(:project, group: group) }
context 'when iterations are available' do
before do
stub_licensed_features(iterations: true)
end
it 'includes iteration endpoint in project context' do
@project = project_under_group
expect(options[:data]['iterations-endpoint']).to eq(expose_path(api_v4_projects_iterations_path(id: @project.id)))
end
it 'includes iteration endpoint in group context' do
@group = group
expect(options[:data]['iterations-endpoint']).to eq(expose_path(api_v4_groups_iterations_path(id: @group.id)))
end
it 'does not include iterations endpoint for projects under a namespace' do
@project = create(:project, namespace: create(:namespace))
expect(options[:data]['iterations-endpoint']).to be(nil)
end
it 'does not include iterations endpoint in dashboard context' do
expect(options[:data]['iterations-endpoint']).to be(nil)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(filter_bar_iterations: false)
end
it 'does not include iterations endpoint' do
expect(options[:data]['iterations-endpoint']).to be(nil)
end
end
end
context 'when iterations are not available' do
before do
stub_licensed_features(iterations: false)
end
it 'does not include iterations endpoint in project context' do
@project = project_under_group
expect(options[:data]['iterations-endpoint']).to be(nil)
end
it 'does not include iterations endpoint in group context' do
@group = group
expect(options[:data]['iterations-endpoint']).to be(nil)
end
end
end
end
describe 'search_autocomplete_opts' do
......
......@@ -209,6 +209,18 @@ RSpec.describe Issue do
expect(described_class.in_iterations([iteration1])).to eq [iteration1_issue]
end
end
describe '.with_iteration_title' do
it 'returns only issues with iterations that match the title' do
expect(described_class.with_iteration_title(iteration1.title)).to eq [iteration1_issue]
end
end
describe '.without_iteration_title' do
it 'returns only issues without iterations or have iterations that do not match the title' do
expect(described_class.without_iteration_title(iteration1.title)).to contain_exactly(issue_no_iteration, iteration2_issue)
end
end
end
context 'status page published' do
......
......@@ -179,6 +179,38 @@ RSpec.describe API::Issues, :mailer do
it_behaves_like 'filtering by epic_id' do
let(:endpoint) { '/issues' }
end
context 'filtering by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group) }
let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: group_project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) }
let_it_be(:no_iteration_issue) { create(:issue, project: group_project) }
it 'returns issues with specific iteration' do
get api('/issues', user), params: { iteration_id: iteration_1.id }
expect_response_contain_exactly(iteration_1_issue.id)
end
it 'returns issues with no iteration' do
get api('/issues', user), params: { iteration_id: 'None' }
expect_response_contain_exactly(no_iteration_issue.id)
end
it 'returns issues with any iteration' do
get api('/issues', user), params: { iteration_id: 'Any' }
expect_response_contain_exactly(iteration_1_issue.id, iteration_2_issue.id)
end
it 'returns issues with a specific iteration title' do
get api('/issues', user), params: { iteration_title: iteration_1.title }
expect_response_contain_exactly(iteration_1_issue.id)
end
end
end
end
......
......@@ -5,6 +5,9 @@ module API
module IssuesHelpers
extend Grape::API::Helpers
params :negatable_issue_filter_params_ee do
end
params :optional_issue_params_ee do
end
......
......@@ -28,6 +28,8 @@ module API
coerce_with: Validations::Validators::CheckAssigneesCount.coerce,
desc: 'Return issues which are assigned to the user with the given username'
mutually_exclusive :assignee_id, :assignee_username
use :negatable_issue_filter_params_ee
end
params :issues_stats_params do
......
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