Commit ffdaa910 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '9029-filter-issue-list-by-sub-epic' into 'master'

Add icon to subepics issues when filtering by epic

See merge request gitlab-org/gitlab!27212
parents 0b4643a7 b689fc8e
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchContainer from '../filtered_search/container';
import FilteredSearchManager from '../filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store'; import boardsStore from './stores/boards_store';
export default class FilteredSearchBoards extends FilteredSearchManager { export default class FilteredSearchBoards extends FilteredSearchManager {
......
...@@ -710,13 +710,17 @@ export default class FilteredSearchManager { ...@@ -710,13 +710,17 @@ export default class FilteredSearchManager {
} }
} }
search(state = null) { getSearchTokens() {
const paths = [];
const searchQuery = DropdownUtils.getSearchQuery(); const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys(); const tokenKeys = this.filteredSearchTokenKeys.getKeys();
const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys); return this.tokenizer.processTokens(searchQuery, tokenKeys);
}
search(state = null) {
const paths = [];
const { tokens, searchToken } = this.getSearchTokens();
const currentState = state || getParameterByName('state') || 'opened'; const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
......
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
export default ({ export default ({
page, page,
......
...@@ -7,6 +7,12 @@ ...@@ -7,6 +7,12 @@
#{'.text-#{$variant}-#{$suffix}'} { #{'.text-#{$variant}-#{$suffix}'} {
color: $color; color: $color;
} }
#{'.hover-text-#{$variant}-#{$suffix}'} {
&:hover {
color: $color;
}
}
} }
} }
......
...@@ -6,12 +6,13 @@ ...@@ -6,12 +6,13 @@
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable"
.issuable-info-container .issuable-info-container
.issuable-main-info .issuable-main-info
.issue-title.title .issue-title.title.d-flex.align-items-center
%span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" }
- if issue.confidential? - if issue.confidential?
%span.has-tooltip{ title: _('Confidential') } %span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue) = confidential_icon(issue)
= link_to issue.title, issue_path(issue) = link_to issue.title, issue_path(issue)
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks? - if issue.tasks?
%span.task-status.d-none.d-sm-inline-block %span.task-status.d-none.d-sm-inline-block
   
......
import CodeReviewAnalyticsFilteredSearchTokenKeys from './code_review_analytics_filtered_search_token_keys'; import CodeReviewAnalyticsFilteredSearchTokenKeys from './code_review_analytics_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import store from './store'; import store from './store';
......
import ProductivityAnalyticsFilteredSearchTokenKeys from './productivity_analytics_filtered_search_token_keys'; import ProductivityAnalyticsFilteredSearchTokenKeys from './productivity_analytics_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import store from './store'; import store from './store';
......
import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import { epicTokenKey } from './issuable_filtered_search_token_keys';
export default class extends FilteredSearchManager {
getSearchTokens() {
const { tokens, ...rest } = super.getSearchTokens();
const hasEqualsToEpicIdToken = tokens.some(
token =>
token?.key === epicTokenKey.key &&
token?.operator === '=' &&
!Number.isNaN(Number(token?.value)),
);
if (hasEqualsToEpicIdToken) {
tokens.push({
key: 'include_subepics',
operator: '=',
value: '',
symbol: '',
});
}
return { tokens, ...rest };
}
}
...@@ -6,7 +6,7 @@ import { ...@@ -6,7 +6,7 @@ import {
} from '~/filtered_search/issuable_filtered_search_token_keys'; } from '~/filtered_search/issuable_filtered_search_token_keys';
import { __ } from '~/locale'; import { __ } from '~/locale';
const weightTokenKey = { export const weightTokenKey = {
formattedKey: __('Weight'), formattedKey: __('Weight'),
key: 'weight', key: 'weight',
type: 'string', type: 'string',
...@@ -16,7 +16,7 @@ const weightTokenKey = { ...@@ -16,7 +16,7 @@ const weightTokenKey = {
tag: 'number', tag: 'number',
}; };
const epicTokenKey = { export const epicTokenKey = {
formattedKey: __('Epic'), formattedKey: __('Epic'),
key: 'epic', key: 'epic',
type: 'string', type: 'string',
...@@ -25,7 +25,7 @@ const epicTokenKey = { ...@@ -25,7 +25,7 @@ const epicTokenKey = {
icon: 'epic', icon: 'epic',
}; };
const weightConditions = [ export const weightConditions = [
{ {
url: 'weight=None', url: 'weight=None',
operator: '=', operator: '=',
...@@ -52,7 +52,7 @@ const weightConditions = [ ...@@ -52,7 +52,7 @@ const weightConditions = [
}, },
]; ];
const epicConditions = [ export const epicConditions = [
{ {
url: 'epic_id=None', url: 'epic_id=None',
operator: '=', operator: '=',
......
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import { historyPushState } from '~/lib/utils/common_utils'; import { historyPushState } from '~/lib/utils/common_utils';
import issueAnalyticsStore from './stores'; import issueAnalyticsStore from './stores';
......
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
const AUTHOR_PARAM_KEY = 'author_username'; const AUTHOR_PARAM_KEY = 'author_username';
......
...@@ -10,9 +10,19 @@ module EE ...@@ -10,9 +10,19 @@ module EE
@preload_for_collection ||= case collection_type @preload_for_collection ||= case collection_type
when 'MergeRequest' when 'MergeRequest'
super.push(:approvals, :approval_rules) super.push(:approvals, :approval_rules)
when 'Issue'
super.push(*issue_preloads)
else else
super super
end end
end end
private
def issue_preloads
[].tap do |issue_params|
issue_params << :epic_issue if params[:include_subepics].present?
end
end
end end
end end
...@@ -11,7 +11,7 @@ module EE ...@@ -11,7 +11,7 @@ module EE
override :scalar_params override :scalar_params
def scalar_params def scalar_params
@scalar_params ||= super + [:weight, :epic_id] @scalar_params ||= super + [:weight, :epic_id, :include_subepics]
end end
end end
......
...@@ -40,6 +40,17 @@ module EE ...@@ -40,6 +40,17 @@ module EE
end end
end end
def issue_in_subepic?(issue, epic_id)
# This helper is used if a list of issues are filtered by epic id
return false if epic_id.blank?
return false if %w(any none).include?(epic_id)
return false if issue.epic_issue.nil?
# An issue is member of a subepic when its epic id is different
# than the filter epic id on params
epic_id.to_i != issue.epic_issue.epic_id
end
override :show_moved_service_desk_issue_warning? override :show_moved_service_desk_issue_warning?
def show_moved_service_desk_issue_warning?(issue) def show_moved_service_desk_issue_warning?(issue)
return false unless issue.moved_from return false unless issue.moved_from
......
- return unless issue_in_subepic?(issue, params[:epic_id])
%span.d-inline-block.ml-1{ title: _('This issue is in a child epic of the filtered epic'), data: { toggle: 'tooltip', container: 'body' } }
= sprite_icon('information-o', size: 16, css_class: 'd-block text-primary-500 hover-text-primary-800')
---
title: Add an indicator icon to issues on subepics when filtering by epic
merge_request: 27212
author:
type: added
import { FILTERED_SEARCH } from '~/pages/constants';
import FilteredSearchManager from 'ee/filtered_search/filtered_search_manager';
import IssuableFilteredSearchTokenKeys from 'ee/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper';
const TEST_EPICS_ENDPOINT = '/test/epics/endpoint';
describe('Filtered Search Manager (EE)', () => {
let manager;
const createSubject = () => {
manager = new FilteredSearchManager({
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
manager.setup();
};
const findSearchInput = () => document.querySelector('.filtered-search');
const findTokensContainer = () => document.querySelector('.tokens-container');
const createVisualToken = (name, operator, value) => {
findTokensContainer().appendChild(
FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value),
);
};
beforeEach(() => {
setFixtures(`
<div class="filtered-search-box">
<form>
<ul class="tokens-container list-unstyled">
${FilteredSearchSpecHelper.createInputHTML()}
</ul>
<button class="clear-search" type="button">
<i class="fa fa-times"></i>
</button>
</form>
</div>
`);
const search = findSearchInput();
search.dataset.epicsEndpoint = TEST_EPICS_ENDPOINT;
jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation();
});
afterEach(() => {
manager.cleanup();
});
describe('getSearchTokens', () => {
describe('Epic token', () => {
beforeEach(() => {
createSubject();
});
it.each`
token | extraTokens
${{ key: 'epic', operator: '=', value: '1' }} | ${[{ key: 'include_subepics', operator: '=', value: '', symbol: '' }]}
${{ key: 'epic', operator: '=', value: 'any' }} | ${[]}
${{ key: 'epic', operator: '!=', value: '1' }} | ${[]}
`('handles include_subepics with $token', ({ token, extraTokens }) => {
createVisualToken(token.key, token.operator, token.value);
const { tokens } = manager.getSearchTokens();
expect(tokens).toEqual([
{ key: token.key, operator: token.operator, value: token.value.toString(), symbol: '' },
...extraTokens,
]);
});
});
});
});
...@@ -43,6 +43,33 @@ describe EE::IssuesHelper do ...@@ -43,6 +43,33 @@ describe EE::IssuesHelper do
end end
end end
describe '#issue_in_subepic?' do
let_it_be(:epic) { create(:epic) }
let_it_be(:epic_issue) { create(:epic_issue, epic: epic) }
let(:issue) { build_stubbed(:issue, epic_issue: epic_issue) }
let(:new_issue) { build_stubbed(:issue) }
it 'returns false if epic_id parameter is not set or is wildcard' do
['', nil, 'none', 'any'].each do |epic_id|
expect(helper.issue_in_subepic?(issue, epic_id)).to be_falsy
end
end
it 'returns false if epic_id parameter is the same as issue epic_id' do
expect(helper.issue_in_subepic?(issue, epic.id)).to be_falsy
end
it 'returns false if the issue is not part of an epic' do
expect(helper.issue_in_subepic?(new_issue, epic.id)).to be_falsy
end
it 'returns true if epic_id parameter is not the same as issue epic_id' do
# When issue_in_subepic? is used, any epic with a different
# id than the one on the params is considered a child
expect(helper.issue_in_subepic?(issue, 'subepic_id')).to be_truthy
end
end
describe '#show_moved_service_desk_issue_warning?' do describe '#show_moved_service_desk_issue_warning?' do
let(:project1) { create(:project, service_desk_enabled: true) } let(:project1) { create(:project, service_desk_enabled: true) }
let(:project2) { create(:project, service_desk_enabled: true) } let(:project2) { create(:project, service_desk_enabled: true) }
......
...@@ -4,6 +4,7 @@ require 'spec_helper' ...@@ -4,6 +4,7 @@ require 'spec_helper'
describe Projects::IssuesController do describe Projects::IssuesController do
let_it_be(:issue) { create(:issue) } let_it_be(:issue) { create(:issue) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { issue.project } let_it_be(:project) { issue.project }
let_it_be(:user) { issue.author } let_it_be(:user) { issue.author }
let_it_be(:blocking_issue) { create(:issue, project: project) } let_it_be(:blocking_issue) { create(:issue, project: project) }
...@@ -38,4 +39,31 @@ describe Projects::IssuesController do ...@@ -38,4 +39,31 @@ describe Projects::IssuesController do
end end
end end
end end
describe 'GET #index' do
def get_issues
get project_issues_path(project, params: params)
end
context 'when listing epic issues' do
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:subepic) { create(:epic, group: group, parent: epic) }
let(:params) { { epic_id: epic.id, include_subepics: true } }
before do
get_issues # Warm the cache
end
it 'does not cause extra queries when there are other subepic issues' do
create(:epic_issue, issue: issue, epic: epic)
control = ActiveRecord::QueryRecorder.new { get_issues }
subepic_issue = create(:issue, project: project)
create(:epic_issue, issue: subepic_issue, epic: subepic)
expect { get_issues }.not_to exceed_query_limit(control)
end
end
end
end end
...@@ -21203,6 +21203,9 @@ msgstr "" ...@@ -21203,6 +21203,9 @@ msgstr ""
msgid "This issue is currently blocked by the following issues: %{issues}." msgid "This issue is currently blocked by the following issues: %{issues}."
msgstr "" msgstr ""
msgid "This issue is in a child epic of the filtered epic"
msgstr ""
msgid "This issue is locked." msgid "This issue is locked."
msgstr "" msgstr ""
......
export default class FilteredSearchSpecHelper {
static createFilterVisualTokenHTML(name, operator, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
.outerHTML;
}
static createFilterVisualToken(name, operator, value, isSelected = false) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
<div class="value-container">
<div class="value">${value}</div>
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
</div>
</div>
`;
return li;
}
static createNameFilterVisualTokenHTML(name) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
</li>
`;
}
static createNameOperatorFilterVisualTokenHTML(name, operator) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
</li>
`;
}
static createSearchVisualToken(name) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term');
li.innerHTML = `<div class="name">${name}</div>`;
return li;
}
static createSearchVisualTokenHTML(name) {
return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
}
static createInputHTML(placeholder = '', value = '') {
return `
<li class="input-token">
<input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
</li>
`;
}
static createTokensContainerHTML(html, inputPlaceholder) {
return `
${html}
${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
`;
}
}
export default class FilteredSearchSpecHelper { export { default } from '../../frontend/helpers/filtered_search_spec_helper';
static createFilterVisualTokenHTML(name, operator, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
.outerHTML;
}
static createFilterVisualToken(name, operator, value, isSelected = false) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
<div class="value-container">
<div class="value">${value}</div>
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
</div>
</div>
`;
return li;
}
static createNameFilterVisualTokenHTML(name) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
</li>
`;
}
static createNameOperatorFilterVisualTokenHTML(name, operator) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
</li>
`;
}
static createSearchVisualToken(name) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term');
li.innerHTML = `<div class="name">${name}</div>`;
return li;
}
static createSearchVisualTokenHTML(name) {
return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
}
static createInputHTML(placeholder = '', value = '') {
return `
<li class="input-token">
<input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
</li>
`;
}
static createTokensContainerHTML(html, inputPlaceholder) {
return `
${html}
${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
`;
}
}
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