Skip to content
Snippets Groups Projects
Commit 974a0402 authored by Clement Ho's avatar Clement Ho Committed by Phil Hughes
Browse files

Add filtered search to group issue dashboard

parent 6f66b19b
No related branches found
No related tags found
No related merge requests found
Showing
with 400 additions and 45 deletions
Loading
Loading
@@ -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();
Loading
Loading
@@ -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();
}
Loading
Loading
@@ -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;
Loading
Loading
Loading
Loading
@@ -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;
Loading
Loading
@@ -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);
},
Loading
Loading
Loading
Loading
@@ -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 = {
Loading
Loading
@@ -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.');
Loading
Loading
Loading
Loading
@@ -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;
Loading
Loading
Loading
Loading
@@ -54,6 +54,7 @@ class FilteredSearchDropdownManager {
extraArguments: {
endpoint: `${this.baseEndpoint}/labels.json`,
symbol: '~',
preprocessing: gl.DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
Loading
Loading
Loading
Loading
@@ -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);
}
 
Loading
Loading
Loading
Loading
@@ -58,29 +58,54 @@ 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((labels) => {
const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
if (!matchingLabel) {
return;
}
.then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then((labels) => {
const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue);
 
const tokenValueStyle = tokenValueContainer.style;
tokenValueStyle.backgroundColor = matchingLabel.color;
tokenValueStyle.color = matchingLabel.text_color;
if (!matchingLabel) {
return;
}
 
if (matchingLabel.text_color === '#FFFFFF') {
const removeToken = tokenValueContainer.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
FilteredSearchVisualTokens
.setTokenStyle(tokenValueContainer, matchingLabel.color, matchingLabel.text_color);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
 
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
Loading
Loading
Loading
Loading
@@ -3,6 +3,7 @@
/* global ListLabel */
 
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import DropdownUtils from './filtered_search/dropdown_utils';
 
(function() {
this.LabelsSelect = (function() {
Loading
Loading
@@ -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) {
Loading
Loading
Loading
Loading
@@ -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));
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
- 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 }
Loading
Loading
@@ -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
Loading
Loading
---
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")}
 
Loading
Loading
@@ -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
Loading
Loading
@@ -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);
});
});
});
});
Loading
Loading
@@ -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')
Loading
Loading
Loading
Loading
@@ -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';
Loading
Loading
Loading
Loading
@@ -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;
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment