Commit c73f0c48 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'add-filtered-search-group-issues-ce' into 'master'

Add filtered search to group issue dashboard

Closes #33575

See merge request !13167
parents 6f66b19b 974a0402
......@@ -139,6 +139,8 @@ import GpgBadges from './gpg_badges';
.init();
}
const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search');
switch (page) {
case 'profiles:preferences:show':
initExperimentalFlags();
......@@ -155,7 +157,7 @@ import GpgBadges from './gpg_badges';
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
if (filteredSearchEnabled) {
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup();
}
......@@ -183,11 +185,17 @@ import GpgBadges from './gpg_badges';
break;
case 'dashboard:issues':
case 'dashboard:merge_requests':
case 'groups:issues':
case 'groups:merge_requests':
new ProjectSelect();
initLegacyFilters();
break;
case 'groups:issues':
if (filteredSearchEnabled) {
const filteredSearchManager = new gl.FilteredSearchManager('issues');
filteredSearchManager.setup();
}
new ProjectSelect();
break;
case 'dashboard:todos:index':
new Todos();
break;
......
......@@ -11,6 +11,16 @@ const Ajax = {
if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data);
},
preprocessing: function preprocessing(config, data) {
let results = data;
if (config.preprocessing && !data.preprocessed) {
results = config.preprocessing(data);
AjaxCache.override(config.endpoint, results);
}
return results;
},
init: function init(hook) {
var self = this;
self.destroyed = false;
......@@ -31,7 +41,8 @@ const Ajax = {
dynamicList.outerHTML = loadingTemplate.outerHTML;
}
AjaxCache.retrieve(config.endpoint)
return AjaxCache.retrieve(config.endpoint)
.then(self.preprocessing.bind(null, config))
.then((data) => self._loadData(data, config, self))
.catch(config.onError);
},
......
......@@ -6,7 +6,7 @@ import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown {
constructor(options = {}) {
const { input, endpoint, symbol } = options;
const { input, endpoint, symbol, preprocessing } = options;
super(options);
this.symbol = symbol;
this.config = {
......@@ -14,6 +14,7 @@ class DropdownNonUser extends gl.FilteredSearchDropdown {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
preprocessing,
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
......
......@@ -50,6 +50,66 @@ class DropdownUtils {
return updatedItem;
}
static mergeDuplicateLabels(dataMap, newLabel) {
const updatedMap = dataMap;
const key = newLabel.title;
const hasKeyProperty = Object.prototype.hasOwnProperty.call(updatedMap, key);
if (!hasKeyProperty) {
updatedMap[key] = newLabel;
} else {
const existing = updatedMap[key];
if (!existing.multipleColors) {
existing.multipleColors = [existing.color];
}
existing.multipleColors.push(newLabel.color);
}
return updatedMap;
}
static duplicateLabelColor(labelColors) {
const colors = labelColors;
const spacing = 100 / colors.length;
// Reduce the colors to 4
colors.length = Math.min(colors.length, 4);
const color = colors.map((c, i) => {
const percentFirst = Math.floor(spacing * i);
const percentSecond = Math.floor(spacing * (i + 1));
return `${c} ${percentFirst}%, ${c} ${percentSecond}%`;
}).join(', ');
return `linear-gradient(${color})`;
}
static duplicateLabelPreprocessing(data) {
const results = [];
const dataMap = {};
data.forEach(DropdownUtils.mergeDuplicateLabels.bind(null, dataMap));
Object.keys(dataMap)
.forEach((key) => {
const label = dataMap[key];
if (label.multipleColors) {
label.color = DropdownUtils.duplicateLabelColor(label.multipleColors);
label.text_color = '#000000';
}
results.push(label);
});
results.preprocessed = true;
return results;
}
static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item;
......
......@@ -54,6 +54,7 @@ class FilteredSearchDropdownManager {
extraArguments: {
endpoint: `${this.baseEndpoint}/labels.json`,
symbol: '~',
preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
......
......@@ -20,13 +20,13 @@ class FilteredSearchManager {
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
});
this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
const projectPath = this.searchHistoryDropdownElement ?
this.searchHistoryDropdownElement.dataset.projectFullPath : 'project';
const fullPath = this.searchHistoryDropdownElement ?
this.searchHistoryDropdownElement.dataset.fullPath : 'project';
let recentSearchesPagePrefix = 'issue-recent-searches';
if (this.page === 'merge_requests') {
recentSearchesPagePrefix = 'merge-request-recent-searches';
}
const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`;
const recentSearchesKey = `${fullPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
......
......@@ -58,12 +58,43 @@ class FilteredSearchVisualTokens {
`;
}
static setTokenStyle(tokenContainer, backgroundColor, textColor) {
const token = tokenContainer;
// Labels with linear gradient should not override default background color
if (backgroundColor.indexOf('linear-gradient') === -1) {
token.style.backgroundColor = backgroundColor;
}
token.style.color = textColor;
if (textColor === '#FFFFFF') {
const removeToken = token.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
return token;
}
static preprocessLabel(labelsEndpoint, labels) {
let processed = labels;
if (!labels.preprocessed) {
processed = gl.DropdownUtils.duplicateLabelPreprocessing(labels);
AjaxCache.override(labelsEndpoint, processed);
processed.preprocessed = true;
}
return processed;
}
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const baseEndpoint = filteredSearchInput.dataset.baseEndpoint;
const labelsEndpoint = `${baseEndpoint}/labels.json`;
return AjaxCache.retrieve(labelsEndpoint)
.then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then((labels) => {
const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
......@@ -71,14 +102,8 @@ class FilteredSearchVisualTokens {
return;
}
const tokenValueStyle = tokenValueContainer.style;
tokenValueStyle.backgroundColor = matchingLabel.color;
tokenValueStyle.color = matchingLabel.text_color;
if (matchingLabel.text_color === '#FFFFFF') {
const removeToken = tokenValueContainer.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
FilteredSearchVisualTokens
.setTokenStyle(tokenValueContainer, matchingLabel.color, matchingLabel.text_color);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
......
......@@ -3,6 +3,7 @@
/* global ListLabel */
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import DropdownUtils from './filtered_search/dropdown_utils';
(function() {
this.LabelsSelect = (function() {
......@@ -218,18 +219,7 @@ import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
}
}
if (label.duplicate) {
spacing = 100 / label.color.length;
// Reduce the colors to 4
label.color = label.color.filter(function(color, i) {
return i < 4;
});
color = _.map(label.color, function(color, i) {
var percentFirst, percentSecond;
percentFirst = Math.floor(spacing * i);
percentSecond = Math.floor(spacing * (i + 1));
return color + " " + percentFirst + "%," + color + " " + percentSecond + "% ";
}).join(',');
color = "linear-gradient(" + color + ")";
color = gl.DropdownUtils.duplicateLabelColor(label.color);
}
else {
if (label.color != null) {
......
......@@ -6,6 +6,10 @@ class AjaxCache extends Cache {
this.pendingRequests = { };
}
override(endpoint, data) {
this.internalStorage[endpoint] = data;
}
retrieve(endpoint, forceRetrieve) {
if (this.hasData(endpoint) && !forceRetrieve) {
return Promise.resolve(this.get(endpoint));
......
......@@ -127,15 +127,23 @@ module SearchHelper
end
def search_filter_input_options(type)
{
opts = {
id: "filtered-search-#{type}",
placeholder: 'Search or filter results...',
data: {
'project-id' => @project.id,
'username-params' => @users.to_json(only: [:id, :username]),
'base-endpoint' => project_path(@project)
'username-params' => @users.to_json(only: [:id, :username])
}
}
if @project.present?
opts[:data]['project-id'] = @project.id
opts[:data]['base-endpoint'] = project_path(@project)
else
# Group context
opts[:data]['base-endpoint'] = group_canonical_path(@group)
end
opts
end
# Sanitize a HTML field for search display. Most tags are stripped out and the
......
......@@ -4,6 +4,10 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues")
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
- if show_new_nav? && group_issues_exists
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do
......@@ -20,7 +24,7 @@
Subscribe
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
= render 'shared/issuable/filter', type: :issues
= render 'shared/issuable/search_bar', type: :issues
.row-content-block.second-block
Only issues from the
......
- type = local_assigns.fetch(:type)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- full_path = @project.present? ? @project.full_path : @group.full_path
.issues-filters
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
......@@ -18,7 +19,7 @@
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
.js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } }
.js-filtered-search-history-dropdown{ data: { full_path: full_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
......
---
title: Add filtered search to group issue dashboard
merge_request:
author:
require 'spec_helper'
feature 'Group issues page' do
include FilteredSearchHelpers
let(:path) { issues_group_path(group) }
let(:issuable) { create(:issue, project: project, title: "this is my created issuable")}
......@@ -31,12 +33,10 @@ feature 'Group issues page' do
let(:path) { issues_group_path(group) }
it 'filters by only group users' do
click_button('Assignee')
wait_for_requests
filtered_search.set('assignee:')
expect(find('.dropdown-menu-assignee')).to have_link(user.name)
expect(find('.dropdown-menu-assignee')).not_to have_link(user2.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
end
end
end
......@@ -68,4 +68,38 @@ describe SearchHelper do
end
end
end
describe 'search_filter_input_options' do
context 'project' do
before do
@project = create(:project, :repository)
end
it 'includes id with type' do
expect(search_filter_input_options('type')[:id]).to eq('filtered-search-type')
end
it 'includes project-id' do
expect(search_filter_input_options('')[:data]['project-id']).to eq(@project.id)
end
it 'includes project base-endpoint' do
expect(search_filter_input_options('')[:data]['base-endpoint']).to eq(project_path(@project))
end
end
context 'group' do
before do
@group = create(:group, name: 'group')
end
it 'does not includes project-id' do
expect(search_filter_input_options('')[:data]['project-id']).to eq(nil)
end
it 'includes group base-endpoint' do
expect(search_filter_input_options('')[:data]['base-endpoint']).to eq("/groups#{group_path(@group)}")
end
end
end
end
import AjaxCache from '~/lib/utils/ajax_cache';
import Ajax from '~/droplab/plugins/ajax';
describe('Ajax', () => {
describe('preprocessing', () => {
const config = {};
describe('is not configured', () => {
it('passes the data through', () => {
const data = ['data'];
expect(Ajax.preprocessing(config, data)).toEqual(data);
});
});
describe('is configured', () => {
const processedArray = ['processed'];
beforeEach(() => {
config.preprocessing = () => processedArray;
spyOn(config, 'preprocessing').and.callFake(() => processedArray);
});
it('calls preprocessing', () => {
Ajax.preprocessing(config, []);
expect(config.preprocessing.calls.count()).toBe(1);
});
it('overrides AjaxCache', () => {
spyOn(AjaxCache, 'override').and.callFake((endpoint, results) => expect(results).toEqual(processedArray));
Ajax.preprocessing(config, []);
expect(AjaxCache.override.calls.count()).toBe(1);
});
});
});
});
......@@ -191,6 +191,102 @@ describe('Dropdown Utils', () => {
});
});
describe('mergeDuplicateLabels', () => {
const dataMap = {
label: {
title: 'label',
color: '#FFFFFF',
},
};
it('should add label to dataMap if it is not a duplicate', () => {
const newLabel = {
title: 'new-label',
color: '#000000',
};
const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, newLabel);
expect(updated[newLabel.title]).toEqual(newLabel);
});
it('should merge colors if label is a duplicate', () => {
const duplicate = {
title: 'label',
color: '#000000',
};
const updated = gl.DropdownUtils.mergeDuplicateLabels(dataMap, duplicate);
expect(updated.label.multipleColors).toEqual([dataMap.label.color, duplicate.color]);
});
});
describe('duplicateLabelColor', () => {
it('should linear-gradient 2 colors', () => {
const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000']);
expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 50%, #000000 50%, #000000 100%)');
});
it('should linear-gradient 3 colors', () => {
const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333']);
expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 33%, #000000 33%, #000000 66%, #333333 66%, #333333 100%)');
});
it('should linear-gradient 4 colors', () => {
const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333', '#DDDDDD']);
expect(gradient).toEqual('linear-gradient(#FFFFFF 0%, #FFFFFF 25%, #000000 25%, #000000 50%, #333333 50%, #333333 75%, #DDDDDD 75%, #DDDDDD 100%)');
});
it('should not linear-gradient more than 4 colors', () => {
const gradient = gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000', '#333333', '#DDDDDD', '#EEEEEE']);
expect(gradient.indexOf('#EEEEEE') === -1).toEqual(true);
});
});
describe('duplicateLabelPreprocessing', () => {
it('should set preprocessed to true', () => {
const results = gl.DropdownUtils.duplicateLabelPreprocessing([]);
expect(results.preprocessed).toEqual(true);
});
it('should not mutate existing data if there are no duplicates', () => {
const data = [{
title: 'label1',
color: '#FFFFFF',
}, {
title: 'label2',
color: '#000000',
}];
const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
expect(results.length).toEqual(2);
expect(results[0]).toEqual(data[0]);
expect(results[1]).toEqual(data[1]);
});
describe('duplicate labels', () => {
const data = [{
title: 'label',
color: '#FFFFFF',
}, {
title: 'label',
color: '#000000',
}];
const results = gl.DropdownUtils.duplicateLabelPreprocessing(data);
it('should merge duplicate labels', () => {
expect(results.length).toEqual(1);
});
it('should convert multiple colored labels into linear-gradient', () => {
expect(results[0].color).toEqual(gl.DropdownUtils.duplicateLabelColor(['#FFFFFF', '#000000']));
});
it('should set multiple colored label text color to black', () => {
expect(results[0].text_color).toEqual('#000000');
});
});
});
describe('setDataValueIfSelected', () => {
beforeEach(() => {
spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
......
......@@ -797,6 +797,69 @@ describe('Filtered Search Visual Tokens', () => {
});
});
describe('setTokenStyle', () => {
let originalTextColor;
beforeEach(() => {
originalTextColor = bugLabelToken.style.color;
});
it('should set backgroundColor', () => {
const originalBackgroundColor = bugLabelToken.style.backgroundColor;
const token = subject.setTokenStyle(bugLabelToken, 'blue', 'white');
expect(token.style.backgroundColor).toEqual('blue');
expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
});
it('should not set backgroundColor when it is a linear-gradient', () => {
const token = subject.setTokenStyle(bugLabelToken, 'linear-gradient(135deg, red, blue)', 'white');
expect(token.style.backgroundColor).toEqual(bugLabelToken.style.backgroundColor);
});
it('should set textColor', () => {
const token = subject.setTokenStyle(bugLabelToken, 'white', 'black');
expect(token.style.color).toEqual('black');
expect(token.style.color).not.toEqual(originalTextColor);
});
it('should add inverted class when textColor is #FFFFFF', () => {
const token = subject.setTokenStyle(bugLabelToken, 'black', '#FFFFFF');
expect(token.style.color).toEqual('rgb(255, 255, 255)');
expect(token.style.color).not.toEqual(originalTextColor);
expect(token.querySelector('.remove-token').classList.contains('inverted')).toEqual(true);
});
});
describe('preprocessLabel', () => {
const endpoint = 'endpoint';
it('does not preprocess more than once', () => {
let labels = [];
spyOn(gl.DropdownUtils, 'duplicateLabelPreprocessing').and.callFake(() => []);
labels = gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
expect(gl.DropdownUtils.duplicateLabelPreprocessing.calls.count()).toEqual(1);
});
describe('not preprocessed before', () => {
it('returns preprocessed labels', () => {
let labels = [];
expect(labels.preprocessed).not.toEqual(true);
labels = gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, labels);
expect(labels.preprocessed).toEqual(true);
});
it('overrides AjaxCache with preprocessed results', () => {
spyOn(AjaxCache, 'override').and.callFake(() => {});
gl.FilteredSearchVisualTokens.preprocessLabel(endpoint, []);
expect(AjaxCache.override.calls.count()).toEqual(1);
});
});
});
describe('updateLabelTokenColor', () => {
const jsonFixtureName = 'labels/project_labels.json';
const dummyEndpoint = '/dummy/endpoint';
......
......@@ -77,6 +77,15 @@ describe('AjaxCache', () => {
});
});
describe('override', () => {
it('overrides existing cache', () => {
AjaxCache.internalStorage.endpoint = 'existing-endpoint';
AjaxCache.override('endpoint', 'new-endpoint');
expect(AjaxCache.internalStorage.endpoint).toEqual('new-endpoint');
});
});
describe('retrieve', () => {
let ajaxSpy;
......
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