Commit eb12ab58 authored by Rajat Jain's avatar Rajat Jain

Add epic in filtered search

This MR adds a new option in the filtered search: Epic. With this
change, when the hint dropdown opens, epic will be an option. Upon
selecting, all the epics in the parent group will be displayed. When an
epic is selected, the only issues contained in the epic are displayed.
This also supports the new NOT operator.
parent 19697710
...@@ -9,7 +9,7 @@ import DropdownUtils from './dropdown_utils'; ...@@ -9,7 +9,7 @@ import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility'; import { mergeUrlParams } from '../lib/utils/url_utility';
export default class AvailableDropdownMappings { export default class AvailableDropdownMappings {
constructor( constructor({
container, container,
runnerTagsEndpoint, runnerTagsEndpoint,
labelsEndpoint, labelsEndpoint,
...@@ -18,7 +18,7 @@ export default class AvailableDropdownMappings { ...@@ -18,7 +18,7 @@ export default class AvailableDropdownMappings {
groupsOnly, groupsOnly,
includeAncestorGroups, includeAncestorGroups,
includeDescendantGroups, includeDescendantGroups,
) { }) {
this.container = container; this.container = container;
this.runnerTagsEndpoint = runnerTagsEndpoint; this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint; this.labelsEndpoint = labelsEndpoint;
......
...@@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager { ...@@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager {
labelsEndpoint = '', labelsEndpoint = '',
milestonesEndpoint = '', milestonesEndpoint = '',
releasesEndpoint = '', releasesEndpoint = '',
epicsEndpoint = '',
tokenizer, tokenizer,
page, page,
isGroup, isGroup,
...@@ -27,6 +28,7 @@ export default class FilteredSearchDropdownManager { ...@@ -27,6 +28,7 @@ export default class FilteredSearchDropdownManager {
this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
this.tokenizer = tokenizer; this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
...@@ -54,16 +56,8 @@ export default class FilteredSearchDropdownManager { ...@@ -54,16 +56,8 @@ export default class FilteredSearchDropdownManager {
setupMapping() { setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys(); const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const availableMappings = new AvailableDropdownMappings(
this.container, const availableMappings = new AvailableDropdownMappings({ ...this });
this.runnerTagsEndpoint,
this.labelsEndpoint,
this.milestonesEndpoint,
this.releasesEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
);
this.mapping = availableMappings.getAllowedMappings(supportedTokens); this.mapping = availableMappings.getAllowedMappings(supportedTokens);
} }
......
...@@ -45,6 +45,11 @@ export default class FilteredSearchManager { ...@@ -45,6 +45,11 @@ export default class FilteredSearchManager {
this.filteredSearchTokenKeys.enableMultipleAssignees(); this.filteredSearchTokenKeys.enableMultipleAssignees();
} }
const { epicsEndpoint } = this.filteredSearchInput.dataset;
if (!epicsEndpoint && this.filteredSearchTokenKeys.removeEpicToken) {
this.filteredSearchTokenKeys.removeEpicToken();
}
this.recentSearchesStore = new RecentSearchesStore({ this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(), isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(), allowedKeys: this.filteredSearchTokenKeys.getKeys(),
...@@ -88,12 +93,20 @@ export default class FilteredSearchManager { ...@@ -88,12 +93,20 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer; this.tokenizer = FilteredSearchTokenizer;
const {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
epicsEndpoint = '',
} = this.filteredSearchInput.dataset;
this.dropdownManager = new FilteredSearchDropdownManager({ this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint: runnerTagsEndpoint,
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', labelsEndpoint,
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', milestonesEndpoint,
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', releasesEndpoint,
releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '', epicsEndpoint,
tokenizer: this.tokenizer, tokenizer: this.tokenizer,
page: this.page, page: this.page,
isGroup: this.isGroup, isGroup: this.isGroup,
......
...@@ -28,6 +28,8 @@ export default class VisualTokenValue { ...@@ -28,6 +28,8 @@ export default class VisualTokenValue {
this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement); this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'my-reaction') { } else if (tokenType === 'my-reaction') {
this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement); this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'epic') {
this.updateEpicLabel(tokenValueContainer, tokenValueElement);
} }
} }
...@@ -83,6 +85,39 @@ export default class VisualTokenValue { ...@@ -83,6 +85,39 @@ export default class VisualTokenValue {
.catch(() => new Flash(__('An error occurred while fetching label colors.'))); .catch(() => new Flash(__('An error occurred while fetching label colors.')));
} }
updateEpicLabel(tokenValueContainer) {
const tokenValue = this.tokenValue.replace(/^&/, '');
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { epicsEndpoint } = filteredSearchInput.dataset;
const epicsEndpointWithParams = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${epicsEndpoint}.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(epicsEndpointWithParams)
.then(epics => {
const matchingEpic = (epics || []).find(epic => epic.id === Number(tokenValue));
if (!matchingEpic) {
return;
}
VisualTokenValue.replaceEpicTitle(tokenValueContainer, matchingEpic.title, matchingEpic.id);
})
.catch(() => new Flash(__('An error occurred while adding formatted title for epic')));
}
static replaceEpicTitle(tokenValueContainer, epicTitle, epicId) {
const tokenContainer = tokenValueContainer;
const valueContainer = tokenContainer.querySelector('.value');
if (valueContainer) {
tokenContainer.dataset.originalValue = valueContainer.innerText;
valueContainer.innerText = `"${epicTitle}"::&${epicId}`;
}
}
static setTokenStyle(tokenValueContainer, backgroundColor, textColor) { static setTokenStyle(tokenValueContainer, backgroundColor, textColor) {
const token = tokenValueContainer; const token = tokenValueContainer;
......
...@@ -410,6 +410,15 @@ ...@@ -410,6 +410,15 @@
} }
} }
> button.dropdown-epic-button {
flex-direction: column;
.reference {
color: $gl-gray-400;
margin-top: $gl-padding-4;
}
}
&.droplab-item-selected i { &.droplab-item-selected i {
visibility: visible; visibility: visible;
} }
......
...@@ -159,6 +159,8 @@ ...@@ -159,6 +159,8 @@
= render_if_exists 'shared/issuable/filter_weight', type: type = render_if_exists 'shared/issuable/filter_weight', type: type
= render_if_exists 'shared/issuable/filter_epic', type: type
%button.clear-search.hidden{ type: 'button' } %button.clear-search.hidden{ type: 'button' }
= icon('times') = icon('times')
.filter-dropdown-container.d-flex.flex-column.flex-md-row .filter-dropdown-container.d-flex.flex-column.flex-md-row
......
...@@ -41,6 +41,7 @@ groups: ...@@ -41,6 +41,7 @@ groups:
- [Label](../project/labels.md) - [Label](../project/labels.md)
- My-reaction - My-reaction
- Confidential - Confidential
- Epic ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/195704) in GitLab 12.8)
- Search for this text - Search for this text
1. Select or type the operator to use for filtering the attribute. The following operators are 1. Select or type the operator to use for filtering the attribute. The following operators are
available: available:
......
...@@ -4,35 +4,28 @@ import DropdownWeight from './dropdown_weight'; ...@@ -4,35 +4,28 @@ import DropdownWeight from './dropdown_weight';
import AvailableDropdownMappingsCE from '~/filtered_search/available_dropdown_mappings'; import AvailableDropdownMappingsCE from '~/filtered_search/available_dropdown_mappings';
export default class AvailableDropdownMappings { export default class AvailableDropdownMappings {
constructor( constructor({
container, container,
runnerTagsEndpoint, runnerTagsEndpoint,
labelsEndpoint, labelsEndpoint,
milestonesEndpoint, milestonesEndpoint,
epicsEndpoint,
releasesEndpoint, releasesEndpoint,
groupsOnly, groupsOnly,
includeAncestorGroups, includeAncestorGroups,
includeDescendantGroups, includeDescendantGroups,
) { }) {
this.container = container; this.container = container;
this.runnerTagsEndpoint = runnerTagsEndpoint; this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint; this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint; this.milestonesEndpoint = milestonesEndpoint;
this.epicsEndpoint = epicsEndpoint;
this.releasesEndpoint = releasesEndpoint; this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly; this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups; this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups; this.includeDescendantGroups = includeDescendantGroups;
this.ceAvailableMappings = new AvailableDropdownMappingsCE( this.ceAvailableMappings = new AvailableDropdownMappingsCE({ ...this });
container,
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
);
} }
getAllowedMappings(supportedTokens) { getAllowedMappings(supportedTokens) {
...@@ -60,6 +53,16 @@ export default class AvailableDropdownMappings { ...@@ -60,6 +53,16 @@ export default class AvailableDropdownMappings {
element: this.container.querySelector('#js-dropdown-weight'), element: this.container.querySelector('#js-dropdown-weight'),
}; };
ceMappings.epic = {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getEpicEndpoint(),
symbol: '&',
},
element: this.container.querySelector('#js-dropdown-epic'),
};
return this.ceAvailableMappings.buildMappings(supportedTokens, ceMappings); return this.ceAvailableMappings.buildMappings(supportedTokens, ceMappings);
} }
...@@ -72,4 +75,8 @@ export default class AvailableDropdownMappings { ...@@ -72,4 +75,8 @@ export default class AvailableDropdownMappings {
return endpoint; return endpoint;
} }
getEpicEndpoint() {
return `${this.epicsEndpoint}.json`;
}
} }
...@@ -16,6 +16,15 @@ const weightTokenKey = { ...@@ -16,6 +16,15 @@ const weightTokenKey = {
tag: 'number', tag: 'number',
}; };
const epicTokenKey = {
formattedKey: __('Epic'),
key: 'epic',
type: 'string',
param: 'id',
symbol: '&',
icon: 'epic',
};
const weightConditions = [ const weightConditions = [
{ {
url: 'weight=None', url: 'weight=None',
...@@ -43,14 +52,42 @@ const weightConditions = [ ...@@ -43,14 +52,42 @@ const weightConditions = [
}, },
]; ];
const epicConditions = [
{
url: 'epic_id=None',
operator: '=',
tokenKey: 'epic',
value: __('None'),
},
{
url: 'epic_id=Any',
operator: '=',
tokenKey: 'epic',
value: __('Any'),
},
{
url: 'not[epic_id]=None',
operator: '!=',
tokenKey: 'epic',
value: __('None'),
},
{
url: 'not[epic_id]=Any',
operator: '!=',
tokenKey: 'epic',
value: __('Any'),
},
];
/** /**
* Filter tokens for issues in EE. * Filter tokens for issues in EE.
*/ */
class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys { class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
constructor() { constructor() {
super([...tokenKeys, weightTokenKey], alternativeTokenKeys, [ super([...tokenKeys, epicTokenKey, weightTokenKey], alternativeTokenKeys, [
...conditions, ...conditions,
...weightConditions, ...weightConditions,
...epicConditions,
]); ]);
} }
...@@ -66,6 +103,13 @@ class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys { ...@@ -66,6 +103,13 @@ class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
assigneeTokenKey.type = 'array'; assigneeTokenKey.type = 'array';
assigneeTokenKey.param = 'username[]'; assigneeTokenKey.param = 'username[]';
} }
removeEpicToken() {
const index = this.tokenKeys.findIndex(token => token.key === epicTokenKey.key);
if (index >= 0) {
this.tokenKeys.splice(index, 1);
}
}
} }
export default new IssuesFilteredSearchTokenKeysEE(); export default new IssuesFilteredSearchTokenKeysEE();
...@@ -10,6 +10,12 @@ module EE ...@@ -10,6 +10,12 @@ module EE
options = super options = super
options[:data][:'multiple-assignees'] = 'true' if search_multiple_assignees?(type) options[:data][:'multiple-assignees'] = 'true' if search_multiple_assignees?(type)
if @project&.group
options[:data]['epics-endpoint'] = group_epics_path(@project.group)
elsif @group.present?
options[:data]['epics-endpoint'] = group_epics_path(@group)
end
options options
end end
......
- type = local_assigns.fetch(:type)
- return unless type == :issues || type == :boards || type == :boards_modal || type == :issues_analytics
#js-dropdown-epic.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{ data: { value: '{{id}}' } }
%button.btn.btn-link.dropdown-epic-button
%span= "{{title}}"
%span.reference= "&" + "{{id}}"
---
title: Add epic in filtered search
merge_request: 22958
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe 'Dropdown epic', :js do
include FilteredSearchHelpers
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_epic) { '#js-dropdown-epic' }
let(:filter_dropdown) { find("#{js_dropdown_epic} .filter-dropdown") }
let!(:epic) { create(:epic, group: group) }
let!(:epic2) { create(:epic, group: group) }
let!(:issue) { create(:issue, project: project) }
before do
stub_licensed_features(epics: true)
group.add_maintainer(user)
sign_in(user)
visit issues_group_path(group)
end
describe 'behavior' do
it 'loads all the epics when opened' do
input_filtered_search('epic=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 2)
end
it 'selects epic and correct title is loaded' do
input_filtered_search('epic=', submit: false, extra_space: false)
wait_for_requests
find('li', text: epic.title).click
expect(find('.filtered-search-token .value').text).to eq("\"#{epic.title}\"::&#{epic.id}")
end
it 'filters issues by epic' do
input_filtered_search('epic=', submit: false, extra_space: false)
wait_for_requests
find('li', text: epic2.title).click
expect(find('.issue-title-text').text).to eq("#{issue.title}")
end
end
end
...@@ -1728,6 +1728,9 @@ msgstr "" ...@@ -1728,6 +1728,9 @@ msgstr ""
msgid "An error occurred when updating the issue weight" msgid "An error occurred when updating the issue weight"
msgstr "" msgstr ""
msgid "An error occurred while adding formatted title for epic"
msgstr ""
msgid "An error occurred while checking group path" msgid "An error occurred while checking group path"
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