Skip to content
Snippets Groups Projects
Commit b51f2a60 authored by Winnie Hellmann's avatar Winnie Hellmann Committed by Jacob Schatz
Browse files

Colorize labels in issue search field

parent 9e041f21
No related branches found
No related tags found
No related merge requests found
Showing
with 431 additions and 7 deletions
import AjaxCache from '~/lib/utils/ajax_cache';
import '~/flash'; /* global Flash */
import FilteredSearchContainer from './container';
 
class FilteredSearchVisualTokens {
Loading
Loading
@@ -48,6 +50,40 @@ class FilteredSearchVisualTokens {
`;
}
 
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;
}
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');
}
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container');
tokenValueContainer.querySelector('.value').innerText = tokenValue;
if (tokenName.toLowerCase() === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
}
}
static addVisualTokenElement(name, value, isSearchTerm) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
Loading
Loading
@@ -55,7 +91,7 @@ class FilteredSearchVisualTokens {
 
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
li.querySelector('.value').innerText = value;
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
}
Loading
Loading
@@ -74,7 +110,7 @@ class FilteredSearchVisualTokens {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
lastVisualToken.querySelector('.name').innerText = name;
lastVisualToken.querySelector('.value').innerText = value;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
}
}
 
Loading
Loading
const AjaxCache = {
internalStorage: { },
get(endpoint) {
return this.internalStorage[endpoint];
},
hasData(endpoint) {
return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint);
},
purge(endpoint) {
delete this.internalStorage[endpoint];
},
retrieve(endpoint) {
if (AjaxCache.hasData(endpoint)) {
return Promise.resolve(AjaxCache.get(endpoint));
}
return new Promise((resolve, reject) => {
$.ajax(endpoint) // eslint-disable-line promise/catch-or-return
.then(data => resolve(data),
(jqXHR, textStatus, errorThrown) => {
const error = new Error(`${endpoint}: ${errorThrown}`);
error.textStatus = textStatus;
reject(error);
},
);
})
.then((data) => { this.internalStorage[endpoint] = data; })
.then(() => AjaxCache.get(endpoint));
},
};
export default AjaxCache;
Loading
Loading
@@ -114,11 +114,21 @@
padding-right: 8px;
 
.fa-close {
color: $gl-text-color-disabled;
color: $gl-text-color-secondary;
}
 
&:hover .fa-close {
color: $gl-text-color-secondary;
color: $gl-text-color;
}
&.inverted {
.fa-close {
color: $gl-text-color-secondary-inverted;
}
&:hover .fa-close {
color: $gl-text-color-inverted;
}
}
}
 
Loading
Loading
Loading
Loading
@@ -101,6 +101,8 @@ $gl-font-size: 14px;
$gl-text-color: rgba(0, 0, 0, .85);
$gl-text-color-secondary: rgba(0, 0, 0, .55);
$gl-text-color-disabled: rgba(0, 0, 0, .35);
$gl-text-color-inverted: rgba(255, 255, 255, 1.0);
$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85);
$gl-text-green: $green-600;
$gl-text-red: $red-500;
$gl-text-orange: $orange-600;
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@ class Dashboard::LabelsController < Dashboard::ApplicationController
labels = LabelsFinder.new(current_user).execute
 
respond_to do |format|
format.json { render json: labels.as_json(only: [:id, :title, :color]) }
format.json { render json: LabelSerializer.new.represent_appearance(labels) }
end
end
end
Loading
Loading
@@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController
 
format.json do
available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute
render json: available_labels.as_json(only: [:id, :title, :color])
render json: LabelSerializer.new.represent_appearance(available_labels)
end
end
end
Loading
Loading
Loading
Loading
@@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
render json: @available_labels.as_json(only: [:id, :title, :color])
render json: LabelSerializer.new.represent_appearance(@available_labels)
end
end
end
Loading
Loading
Loading
Loading
@@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity
expose :group_id
expose :project_id
expose :template
expose :text_color
expose :created_at
expose :updated_at
end
class LabelSerializer < BaseSerializer
entity LabelEntity
def represent_appearance(resource)
represent(resource, { only: [:id, :title, :color, :text_color] })
end
end
---
title: Colorize labels in search field
merge_request: 11047
author:
import AjaxCache from '~/lib/utils/ajax_cache';
require('~/filtered_search/filtered_search_visual_tokens');
const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper');
 
Loading
Loading
@@ -611,4 +613,103 @@ describe('Filtered Search Visual Tokens', () => {
expect(token.querySelector('.value').innerText).toEqual('~bug');
});
});
describe('renderVisualTokenValue', () => {
let searchTokens;
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')}
`);
searchTokens = document.querySelectorAll('.filtered-search-token');
});
it('renders a token value element', () => {
spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor');
const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor;
expect(searchTokens.length).toBe(2);
Array.prototype.forEach.call(searchTokens, (token) => {
updateLabelTokenColorSpy.calls.reset();
const tokenName = token.querySelector('.name').innerText;
const tokenValue = 'new value';
gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue);
const tokenValueElement = token.querySelector('.value');
expect(tokenValueElement.innerText).toBe(tokenValue);
if (tokenName.toLowerCase() === 'label') {
const tokenValueContainer = token.querySelector('.value-container');
expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
const expectedArgs = [tokenValueContainer, tokenValue];
expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
} else {
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
}
});
});
});
describe('updateLabelTokenColor', () => {
const jsonFixtureName = 'labels/project_labels.json';
const dummyEndpoint = '/dummy/endpoint';
preloadFixtures(jsonFixtureName);
const labelData = getJSONFixture(jsonFixtureName);
const findLabel = tokenValue => labelData.find(
label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`,
);
const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist');
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"');
const parseColor = (color) => {
const dummyElement = document.createElement('div');
dummyElement.style.color = color;
return dummyElement.style.color;
};
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML}
${missingLabelToken.outerHTML}
${spaceLabelToken.outerHTML}
`);
const filteredSearchInput = document.querySelector('.filtered-search');
filteredSearchInput.dataset.baseEndpoint = dummyEndpoint;
AjaxCache.internalStorage = { };
AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData;
});
const testCase = (token, done) => {
const tokenValueContainer = token.querySelector('.value-container');
const tokenValue = token.querySelector('.value').innerText;
const label = findLabel(tokenValue);
gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
if (label) {
expect(tokenValueContainer.getAttribute('style')).not.toBe(null);
expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color));
expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color));
} else {
expect(token).toBe(missingLabelToken);
expect(tokenValueContainer.getAttribute('style')).toBe(null);
}
})
.then(done)
.catch(fail);
};
it('updates the color of a label token', done => testCase(bugLabelToken, done));
it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done));
it('does not change color of a missing label', done => testCase(missingLabelToken, done));
});
});
require 'spec_helper'
describe 'Labels (JavaScript fixtures)' do
include JavaScriptFixturesHelpers
let(:admin) { create(:admin) }
let(:group) { create(:group, name: 'frontend-fixtures-group' )}
let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') }
let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') }
let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') }
let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') }
let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') }
let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') }
let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') }
before(:all) do
clean_frontend_fixtures('labels/')
end
describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do
render_views
before(:each) do
sign_in(admin)
end
it 'labels/group_labels.json' do |example|
get :index,
group_id: group,
format: 'json'
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do
render_views
before(:each) do
sign_in(admin)
end
it 'labels/project_labels.json' do |example|
get :index,
namespace_id: group,
project_id: project,
format: 'json'
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
end
import AjaxCache from '~/lib/utils/ajax_cache';
describe('AjaxCache', () => {
const dummyEndpoint = '/AjaxCache/dummyEndpoint';
const dummyResponse = {
important: 'dummy data',
};
let ajaxSpy = (url) => {
expect(url).toBe(dummyEndpoint);
const deferred = $.Deferred();
deferred.resolve(dummyResponse);
return deferred.promise();
};
beforeEach(() => {
AjaxCache.internalStorage = { };
spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url));
});
describe('#get', () => {
it('returns undefined if cache is empty', () => {
const data = AjaxCache.get(dummyEndpoint);
expect(data).toBe(undefined);
});
it('returns undefined if cache contains no matching data', () => {
AjaxCache.internalStorage['not matching'] = dummyResponse;
const data = AjaxCache.get(dummyEndpoint);
expect(data).toBe(undefined);
});
it('returns matching data', () => {
AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
const data = AjaxCache.get(dummyEndpoint);
expect(data).toBe(dummyResponse);
});
});
describe('#hasData', () => {
it('returns false if cache is empty', () => {
expect(AjaxCache.hasData(dummyEndpoint)).toBe(false);
});
it('returns false if cache contains no matching data', () => {
AjaxCache.internalStorage['not matching'] = dummyResponse;
expect(AjaxCache.hasData(dummyEndpoint)).toBe(false);
});
it('returns true if data is available', () => {
AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
expect(AjaxCache.hasData(dummyEndpoint)).toBe(true);
});
});
describe('#purge', () => {
it('does nothing if cache is empty', () => {
AjaxCache.purge(dummyEndpoint);
expect(AjaxCache.internalStorage).toEqual({ });
});
it('does nothing if cache contains no matching data', () => {
AjaxCache.internalStorage['not matching'] = dummyResponse;
AjaxCache.purge(dummyEndpoint);
expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse);
});
it('removes matching data', () => {
AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
AjaxCache.purge(dummyEndpoint);
expect(AjaxCache.internalStorage).toEqual({ });
});
});
describe('#retrieve', () => {
it('stores and returns data from Ajax call if cache is empty', (done) => {
AjaxCache.retrieve(dummyEndpoint)
.then((data) => {
expect(data).toBe(dummyResponse);
expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse);
})
.then(done)
.catch(fail);
});
it('returns undefined if Ajax call fails and cache is empty', (done) => {
const dummyStatusText = 'exploded';
const dummyErrorMessage = 'server exploded';
ajaxSpy = (url) => {
expect(url).toBe(dummyEndpoint);
const deferred = $.Deferred();
deferred.reject(null, dummyStatusText, dummyErrorMessage);
return deferred.promise();
};
AjaxCache.retrieve(dummyEndpoint)
.then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`))
.catch((error) => {
expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`);
expect(error.textStatus).toBe(dummyStatusText);
done();
})
.catch(fail);
});
it('makes no Ajax call if matching data exists', (done) => {
AjaxCache.internalStorage[dummyEndpoint] = dummyResponse;
ajaxSpy = () => fail(new Error('expected no Ajax call!'));
AjaxCache.retrieve(dummyEndpoint)
.then((data) => {
expect(data).toBe(dummyResponse);
})
.then(done)
.catch(fail);
});
});
});
require 'spec_helper'
describe LabelSerializer do
let(:user) { create(:user) }
let(:serializer) do
described_class.new(user: user)
end
subject { serializer.represent(resource) }
describe '#represent' do
context 'when a single object is being serialized' do
let(:resource) { create(:label) }
it 'serializes the label object' do
expect(subject[:id]).to eq resource.id
end
end
context 'when multiple objects are being serialized' do
let(:num_labels) { 2 }
let(:resource) { create_list(:label, num_labels) }
it 'serializes the array of labels' do
expect(subject.size).to eq(num_labels)
end
end
end
describe '#represent_appearance' do
context 'when represents only appearance' do
let(:resource) { create(:label) }
subject { serializer.represent_appearance(resource) }
it 'serializes only attributes used for appearance' do
expect(subject.keys).to eq([:id, :title, :color, :text_color])
expect(subject[:id]).to eq(resource.id)
expect(subject[:title]).to eq(resource.title)
expect(subject[:color]).to eq(resource.color)
expect(subject[:text_color]).to eq(resource.text_color)
end
end
end
end
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