Commit 2563b031 authored by James Lopez's avatar James Lopez

Merge branch 'add-epic-filter' into 'master'

Add epic in filtered search

See merge request gitlab-org/gitlab!22958
parents f9a3c233 eb12ab58
......@@ -9,7 +9,7 @@ import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
export default class AvailableDropdownMappings {
constructor(
constructor({
container,
runnerTagsEndpoint,
labelsEndpoint,
......@@ -18,7 +18,7 @@ export default class AvailableDropdownMappings {
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
) {
}) {
this.container = container;
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
......
......@@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager {
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
epicsEndpoint = '',
tokenizer,
page,
isGroup,
......@@ -27,6 +28,7 @@ export default class FilteredSearchDropdownManager {
this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
......@@ -54,16 +56,8 @@ export default class FilteredSearchDropdownManager {
setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const availableMappings = new AvailableDropdownMappings(
this.container,
this.runnerTagsEndpoint,
this.labelsEndpoint,
this.milestonesEndpoint,
this.releasesEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
);
const availableMappings = new AvailableDropdownMappings({ ...this });
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
......
......@@ -45,6 +45,11 @@ export default class FilteredSearchManager {
this.filteredSearchTokenKeys.enableMultipleAssignees();
}
const { epicsEndpoint } = this.filteredSearchInput.dataset;
if (!epicsEndpoint && this.filteredSearchTokenKeys.removeEpicToken) {
this.filteredSearchTokenKeys.removeEpicToken();
}
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
......@@ -88,12 +93,20 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer;
const {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
epicsEndpoint = '',
} = this.filteredSearchInput.dataset;
this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint:
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '',
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '',
releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '',
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
epicsEndpoint,
tokenizer: this.tokenizer,
page: this.page,
isGroup: this.isGroup,
......
......@@ -28,6 +28,8 @@ export default class VisualTokenValue {
this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'my-reaction') {
this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'epic') {
this.updateEpicLabel(tokenValueContainer, tokenValueElement);
}
}
......@@ -83,6 +85,39 @@ export default class VisualTokenValue {
.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) {
const token = tokenValueContainer;
......
......@@ -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 {
visibility: visible;
}
......
......@@ -159,6 +159,8 @@
= render_if_exists 'shared/issuable/filter_weight', type: type
= render_if_exists 'shared/issuable/filter_epic', type: type
%button.clear-search.hidden{ type: 'button' }
= icon('times')
.filter-dropdown-container.d-flex.flex-column.flex-md-row
......
......@@ -41,6 +41,7 @@ groups:
- [Label](../project/labels.md)
- My-reaction
- Confidential
- Epic ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/195704) in GitLab 12.8)
- Search for this text
1. Select or type the operator to use for filtering the attribute. The following operators are
available:
......
......@@ -4,35 +4,28 @@ import DropdownWeight from './dropdown_weight';
import AvailableDropdownMappingsCE from '~/filtered_search/available_dropdown_mappings';
export default class AvailableDropdownMappings {
constructor(
constructor({
container,
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
epicsEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
) {
}) {
this.container = container;
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.epicsEndpoint = epicsEndpoint;
this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
this.ceAvailableMappings = new AvailableDropdownMappingsCE(
container,
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
);
this.ceAvailableMappings = new AvailableDropdownMappingsCE({ ...this });
}
getAllowedMappings(supportedTokens) {
......@@ -60,6 +53,16 @@ export default class AvailableDropdownMappings {
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);
}
......@@ -72,4 +75,8 @@ export default class AvailableDropdownMappings {
return endpoint;
}
getEpicEndpoint() {
return `${this.epicsEndpoint}.json`;
}
}
......@@ -16,6 +16,15 @@ const weightTokenKey = {
tag: 'number',
};
const epicTokenKey = {
formattedKey: __('Epic'),
key: 'epic',
type: 'string',
param: 'id',
symbol: '&',
icon: 'epic',
};
const weightConditions = [
{
url: 'weight=None',
......@@ -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.
*/
class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
constructor() {
super([...tokenKeys, weightTokenKey], alternativeTokenKeys, [
super([...tokenKeys, epicTokenKey, weightTokenKey], alternativeTokenKeys, [
...conditions,
...weightConditions,
...epicConditions,
]);
}
......@@ -66,6 +103,13 @@ class IssuesFilteredSearchTokenKeysEE extends FilteredSearchTokenKeys {
assigneeTokenKey.type = 'array';
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();
......@@ -10,6 +10,12 @@ module EE
options = super
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
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 ""
msgid "An error occurred when updating the issue weight"
msgstr ""
msgid "An error occurred while adding formatted title for epic"
msgstr ""
msgid "An error occurred while checking group path"
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