Commit 572e3f22 authored by Igor's avatar Igor Committed by Mike Greiling

Provide EE backports for filtering by approver feature

Adds custom validator for ArrayNoneAny param
Extracts some logic in js into separate files
parent 860a4de3
export default IssuableTokenKeys => {
const wipToken = {
key: 'wip',
type: 'string',
param: '',
symbol: '',
icon: 'admin',
tag: 'Yes or No',
lowercaseValueOnSubmit: true,
uppercaseTokenName: true,
capitalizeTokenValue: true,
};
IssuableTokenKeys.tokenKeys.push(wipToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
};
import DropdownHint from './dropdown_hint';
import DropdownUser from './dropdown_user';
import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownUtils from './dropdown_utils';
export default class AvailableDropdownMappings {
constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
this.container = container;
this.baseEndpoint = baseEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
}
getAllowedMappings(supportedTokens) {
return this.buildMappings(supportedTokens, this.getMappings());
}
buildMappings(supportedTokens, availableMappings) {
const allowedMappings = {
hint: {
reference: null,
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
};
supportedTokens.forEach(type => {
if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type];
}
});
return allowedMappings;
}
getMappings() {
return {
author: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getLabelsEndpoint(),
symbol: '~',
preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
confidential: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-confidential'),
},
status: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-status'),
},
type: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-type'),
},
tag: {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.getRunnerTagsEndpoint(),
symbol: '~',
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
};
}
getMilestoneEndpoint() {
return `${this.baseEndpoint}/milestones.json`;
}
getLabelsEndpoint() {
let endpoint = `${this.baseEndpoint}/labels.json?`;
if (this.groupsOnly) {
endpoint = `${endpoint}only_group_labels=true&`;
}
if (this.includeAncestorGroups) {
endpoint = `${endpoint}include_ancestor_groups=true&`;
}
if (this.includeDescendantGroups) {
endpoint = `${endpoint}include_descendant_groups=true`;
}
return endpoint;
}
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
}
}
import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import DropdownHint from './dropdown_hint';
import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
......@@ -50,114 +45,15 @@ export default class FilteredSearchDropdownManager {
setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const allowedMappings = {
hint: {
reference: null,
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
};
const availableMappings = {
author: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getLabelsEndpoint(),
symbol: '~',
preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
confidential: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-confidential'),
},
status: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-status'),
},
type: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-type'),
},
tag: {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.getRunnerTagsEndpoint(),
symbol: '~',
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
};
supportedTokens.forEach(type => {
if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type];
}
});
this.mapping = allowedMappings;
}
getMilestoneEndpoint() {
const endpoint = `${this.baseEndpoint}/milestones.json`;
return endpoint;
}
getLabelsEndpoint() {
let endpoint = `${this.baseEndpoint}/labels.json?`;
if (this.groupsOnly) {
endpoint = `${endpoint}only_group_labels=true&`;
}
if (this.includeAncestorGroups) {
endpoint = `${endpoint}include_ancestor_groups=true&`;
}
if (this.includeDescendantGroups) {
endpoint = `${endpoint}include_descendant_groups=true`;
}
return endpoint;
}
const availableMappings = new AvailableDropdownMappings(
this.container,
this.baseEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
);
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
......
......@@ -88,21 +88,4 @@ export default class FilteredSearchTokenKeys {
this.tokenKeys.push(confidentialToken);
this.tokenKeysWithAlternative.push(confidentialToken);
}
addExtraTokensForMergeRequests() {
const wipToken = {
key: 'wip',
type: 'string',
param: '',
symbol: '',
icon: 'admin',
tag: 'Yes or No',
lowercaseValueOnSubmit: true,
uppercaseTokenName: true,
capitalizeTokenValue: true,
};
this.tokenKeys.push(wipToken);
this.tokenKeysWithAlternative.push(wipToken);
}
}
import _ from 'underscore';
import AjaxCache from '~/lib/utils/ajax_cache';
import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value';
import { objectToQueryString } from '~/lib/utils/common_utils';
import Flash from '../flash';
import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
import DropdownUtils from './dropdown_utils';
export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
......@@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens {
};
}
/**
* Returns a computed API endpoint
* and query string composed of values from endpointQueryParams
* @param {String} endpoint
* @param {String} endpointQueryParams
*/
static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
if (!endpointQueryParams) {
return endpoint;
}
const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll(
'.js-visual-token .selectable.selected',
......@@ -76,114 +57,15 @@ export default class FilteredSearchVisualTokens {
`;
}
static setTokenStyle(tokenContainer, backgroundColor, textColor) {
const token = tokenContainer;
token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
const removeToken = token.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
return token;
}
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${baseEndpoint}/labels.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(labelsEndpoint)
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
);
if (!matchingLabel) {
return;
}
FilteredSearchVisualTokens.setTokenStyle(
tokenValueContainer,
matchingLabel.color,
matchingLabel.text_color,
);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
const username = tokenValue.replace(/^@/, '');
return (
UsersCache.retrieve(username)
.then(user => {
if (!user) {
return;
}
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
// ignore error and leave username in the search bar
.catch(() => {})
);
}
static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
const container = tokenValueContainer;
const element = tokenValueElement;
return (
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(Emoji => {
if (!Emoji.isEmojiNameValid(tokenValue)) {
return;
}
container.dataset.originalValue = tokenValue;
element.innerHTML = Emoji.glEmojiTag(tokenValue);
})
// ignore error and leave emoji name in the search bar
.catch(() => {})
);
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
if (['none', 'any'].includes(tokenValue.toLowerCase())) {
return;
}
const tokenType = tokenName.toLowerCase();
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
if (tokenType === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
} else if (tokenType === 'author' || tokenType === 'assignee') {
FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer,
tokenValueElement,
tokenValue,
);
} else if (tokenType === 'my-reaction') {
FilteredSearchVisualTokens.updateEmojiTokenAppearance(
tokenValueContainer,
tokenValueElement,
tokenValue,
);
}
visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
static addVisualTokenElement(name, value, options = {}) {
......@@ -318,6 +200,21 @@ export default class FilteredSearchVisualTokens {
}
}
/**
* Returns a computed API endpoint
* and query string composed of values from endpointQueryParams
* @param {String} endpoint
* @param {String} endpointQueryParams
*/
static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
if (!endpointQueryParams) {
return endpoint;
}
const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
static editToken(token) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
......
import _ from 'underscore';
import FilteredSearchContainer from '~/filtered_search/container';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
import AjaxCache from '~/lib/utils/ajax_cache';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import Flash from '~/flash';
import UsersCache from '~/lib/utils/users_cache';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
}
render(tokenValueContainer, tokenValueElement) {
const { tokenType } = this;
if (['none', 'any'].includes(tokenType)) {
return;
}
if (tokenType === 'label') {
this.updateLabelTokenColor(tokenValueContainer);
} else if (tokenType === 'author' || tokenType === 'assignee') {
this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'my-reaction') {
this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
}
}
updateUserTokenAppearance(tokenValueContainer, tokenValueElement) {
const { tokenValue } = this;
const username = this.tokenValue.replace(/^@/, '');
return (
UsersCache.retrieve(username)
.then(user => {
if (!user) {
return;
}
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
// ignore error and leave username in the search bar
.catch(() => {})
);
}
updateLabelTokenColor(tokenValueContainer) {
const { tokenValue } = this;
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${baseEndpoint}/labels.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(labelsEndpoint)
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
);
if (!matchingLabel) {
return;
}
VisualTokenValue.setTokenStyle(
tokenValueContainer,
matchingLabel.color,
matchingLabel.text_color,
);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
static setTokenStyle(tokenValueContainer, backgroundColor, textColor) {
const token = tokenValueContainer;
token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
const removeToken = token.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
return token;
}
updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) {
const container = tokenValueContainer;
const element = tokenValueElement;
const value = this.tokenValue;
return (
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(Emoji => {
if (!Emoji.isEmojiNameValid(value)) {
return;
}
container.dataset.originalValue = value;
element.innerHTML = Emoji.glEmojiTag(value);
})
// ignore error and leave emoji name in the search bar
.catch(() => {})
);
}
}
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
......@@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
%ul.content-list.mr-list.issuable-list
- if @merge_requests.exists?
- if @merge_requests.present?
= render @merge_requests
- else
= render 'shared/empty_states/merge_requests'
......
......@@ -71,6 +71,7 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
......
......@@ -22,9 +22,22 @@ module API
message: "should be an integer, 'None' or 'Any'"
end
end
class ArrayNoneAny < Grape::Validations::Base
def validate_param!(attr_name, params)
value = params[attr_name]
return if value.is_a?(Array) ||
[IssuableFinder::FILTER_NONE, IssuableFinder::FILTER_ANY].include?(value.to_s.downcase)
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be an array, 'None' or 'Any'"
end
end
end
end
end
Grape::Validations.register_validator(:absence, ::API::Helpers::CustomValidators::Absence)
Grape::Validations.register_validator(:integer_none_any, ::API::Helpers::CustomValidators::IntegerNoneAny)
Grape::Validations.register_validator(:array_none_any, ::API::Helpers::CustomValidators::ArrayNoneAny)
......@@ -12,6 +12,9 @@ module API
helpers do
params :optional_params_ee do
end
params :optional_merge_requests_search_params do
end
end
def self.update_params_at_least_one_of
......@@ -112,6 +115,8 @@ module API
optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these'
optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :optional_merge_requests_search_params
use :pagination
end
end
......
import _ from 'underscore';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
import DropdownUtils from '~/filtered_search//dropdown_utils';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Visual Tokens', () => {
......@@ -685,349 +680,21 @@ describe('Filtered Search Visual Tokens', () => {
});
describe('renderVisualTokenValue', () => {
const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken(
'milestone',
'upcoming',
);
let updateLabelTokenColorSpy;
let updateUserTokenAppearanceSpy;
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${authorToken.outerHTML}
${bugLabelToken.outerHTML}
${keywordToken.outerHTML}
${milestoneToken.outerHTML}
`);
spyOn(subject, 'updateLabelTokenColor');
updateLabelTokenColorSpy = subject.updateLabelTokenColor;
spyOn(subject, 'updateUserTokenAppearance');
updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
});
it('renders a author token value element', () => {
const { tokenNameElement, tokenValueContainer, tokenValueElement } = findElements(
authorToken,
);
const { tokenNameElement, tokenValueElement } = findElements(authorToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'new value';
subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
expect(tokenValueElement.innerText).toBe(tokenValue);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1);
const expectedArgs = [tokenValueContainer, tokenValueElement, tokenValue];
expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
it('renders a label token value element', () => {
const { tokenNameElement, tokenValueContainer, tokenValueElement } = findElements(
bugLabelToken,
);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'new value';
subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue);
expect(tokenValueElement.innerText).toBe(tokenValue);
expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
const expectedArgs = [tokenValueContainer, tokenValue];
expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('renders a milestone token value element', () => {
const { tokenNameElement, tokenValueElement } = findElements(milestoneToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'new value';
subject.renderVisualTokenValue(milestoneToken, tokenName, tokenValue);
expect(tokenValueElement.innerText).toBe(tokenValue);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update user token appearance for `None` filter', () => {
const { tokenNameElement } = findElements(authorToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'None';
subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update user token appearance for `none` filter', () => {
const { tokenNameElement } = findElements(authorToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'none';
subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update user token appearance for `any` filter', () => {
const { tokenNameElement } = findElements(authorToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'any';
subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update label token color for `none` filter', () => {
const { tokenNameElement } = findElements(bugLabelToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'none';
subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
it('does not update label token color for `any` filter', () => {
const { tokenNameElement } = findElements(bugLabelToken);
const tokenName = tokenNameElement.innerText;
const tokenValue = 'any';
subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
});
describe('updateUserTokenAppearance', () => {
let usersCacheSpy;
beforeEach(() => {
spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username));
});
it('ignores error if UsersCache throws', done => {
spyOn(window, 'Flash');
const dummyError = new Error('Earth rotated backwards');
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.reject(dummyError);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(window.Flash.calls.count()).toBe(0);
})
.then(done)
.catch(done.fail);
});
it('does nothing if user cannot be found', done => {
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(undefined);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueElement.innerText).toBe(tokenValue);
})
.then(done)
.catch(done.fail);
});
it('replaces author token with avatar and display name', done => {
const dummyUser = {
name: 'Important Person',
avatar_url: 'https://host.invalid/mypics/avatar.png',
};
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(dummyUser);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
const avatar = tokenValueElement.querySelector('img.avatar');
expect(avatar.src).toBe(dummyUser.avatar_url);
expect(avatar.alt).toBe('');
})
.then(done)
.catch(done.fail);
});
it('escapes user name when creating token', done => {
const dummyUser = {
name: '<script>',
avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
};
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(dummyUser);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
tokenValueElement.querySelector('.avatar').remove();
expect(tokenValueElement.innerHTML.trim()).toBe(_.escape(dummyUser.name));
})
.then(done)
.catch(done.fail);
});
});
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 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('updateLabelTokenColor', () => {
const jsonFixtureName = 'labels/project_labels.json';
const dummyEndpoint = '/dummy/endpoint';
preloadFixtures(jsonFixtureName);
let labelData;
beforeAll(() => {
labelData = getJSONFixture(jsonFixtureName);
});
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'~doesnotexist',
);
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'~"some space"',
);
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 parseColor = color => {
const dummyElement = document.createElement('div');
dummyElement.style.color = color;
return dummyElement.style.color;
};
const expectValueContainerStyle = (tokenValueContainer, 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));
};
const findLabel = tokenValue =>
labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
it('updates the color of a label token', done => {
const { tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expectValueContainerStyle(tokenValueContainer, matchingLabel);
})
.then(done)
.catch(done.fail);
});
it('updates the color of a label token with spaces', done => {
const { tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expectValueContainerStyle(tokenValueContainer, matchingLabel);
})
.then(done)
.catch(done.fail);
});
it('does not change color of a missing label', done => {
const { tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
expect(matchingLabel).toBe(undefined);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expect(tokenValueContainer.getAttribute('style')).toBe(null);
})
.then(done)
.catch(done.fail);
});
});
});
import VisualTokenValue from '~/filtered_search/visual_token_value';
import _ from 'underscore';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
import DropdownUtils from '~/filtered_search//dropdown_utils';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Filtered Search Visual Tokens', () => {
const findElements = tokenElement => {
const tokenNameElement = tokenElement.querySelector('.name');
const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
const tokenType = tokenNameElement.innerText.toLowerCase();
const tokenValue = tokenValueElement.innerText;
const subject = new VisualTokenValue(tokenValue, tokenType);
return { subject, tokenValueContainer, tokenValueElement };
};
let tokensContainer;
let authorToken;
let bugLabelToken;
beforeEach(() => {
setFixtures(`
<ul class="tokens-container">
${FilteredSearchSpecHelper.createInputHTML()}
</ul>
`);
tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
});
describe('updateUserTokenAppearance', () => {
let usersCacheSpy;
beforeEach(() => {
spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username));
});
it('ignores error if UsersCache throws', done => {
spyOn(window, 'Flash');
const dummyError = new Error('Earth rotated backwards');
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.reject(dummyError);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(window.Flash.calls.count()).toBe(0);
})
.then(done)
.catch(done.fail);
});
it('does nothing if user cannot be found', done => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(undefined);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueElement.innerText).toBe(tokenValue);
})
.then(done)
.catch(done.fail);
});
it('replaces author token with avatar and display name', done => {
const dummyUser = {
name: 'Important Person',
avatar_url: 'https://host.invalid/mypics/avatar.png',
};
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(dummyUser);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
const avatar = tokenValueElement.querySelector('img.avatar');
expect(avatar.src).toBe(dummyUser.avatar_url);
expect(avatar.alt).toBe('');
})
.then(done)
.catch(done.fail);
});
it('escapes user name when creating token', done => {
const dummyUser = {
name: '<script>',
avatar_url: `${gl.TEST_HOST}/mypics/avatar.png`,
};
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = username => {
expect(`@${username}`).toBe(tokenValue);
return Promise.resolve(dummyUser);
};
subject
.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
.then(() => {
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
tokenValueElement.querySelector('.avatar').remove();
expect(tokenValueElement.innerHTML.trim()).toBe(_.escape(dummyUser.name));
})
.then(done)
.catch(done.fail);
});
});
describe('updateLabelTokenColor', () => {
const jsonFixtureName = 'labels/project_labels.json';
const dummyEndpoint = '/dummy/endpoint';
preloadFixtures(jsonFixtureName);
let labelData;
beforeAll(() => {
labelData = getJSONFixture(jsonFixtureName);
});
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'~doesnotexist',
);
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'~"some space"',
);
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 parseColor = color => {
const dummyElement = document.createElement('div');
dummyElement.style.color = color;
return dummyElement.style.color;
};
const expectValueContainerStyle = (tokenValueContainer, 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));
};
const findLabel = tokenValue =>
labelData.find(label => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
it('updates the color of a label token', done => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expectValueContainerStyle(tokenValueContainer, matchingLabel);
})
.then(done)
.catch(done.fail);
});
it('updates the color of a label token with spaces', done => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expectValueContainerStyle(tokenValueContainer, matchingLabel);
})
.then(done)
.catch(done.fail);
});
it('does not change color of a missing label', done => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
expect(matchingLabel).toBe(undefined);
subject
.updateLabelTokenColor(tokenValueContainer, tokenValue)
.then(() => {
expect(tokenValueContainer.getAttribute('style')).toBe(null);
})
.then(done)
.catch(done.fail);
});
});
describe('setTokenStyle', () => {
let originalTextColor;
beforeEach(() => {
originalTextColor = bugLabelToken.style.color;
});
it('should set backgroundColor', () => {
const originalBackgroundColor = bugLabelToken.style.backgroundColor;
const token = VisualTokenValue.setTokenStyle(bugLabelToken, 'blue', 'white');
expect(token.style.backgroundColor).toEqual('blue');
expect(token.style.backgroundColor).not.toEqual(originalBackgroundColor);
});
it('should set textColor', () => {
const token = VisualTokenValue.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 = VisualTokenValue.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('render', () => {
const setupSpies = subject => {
spyOn(subject, 'updateLabelTokenColor'); // eslint-disable-line jasmine/no-unsafe-spy
const updateLabelTokenColorSpy = subject.updateLabelTokenColor;
spyOn(subject, 'updateUserTokenAppearance'); // eslint-disable-line jasmine/no-unsafe-spy
const updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
return { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy };
};
const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search');
const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken(
'milestone',
'upcoming',
);
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${authorToken.outerHTML}
${bugLabelToken.outerHTML}
${keywordToken.outerHTML}
${milestoneToken.outerHTML}
`);
});
it('renders a author token value element', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1);
const expectedArgs = [tokenValueContainer, tokenValueElement];
expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
it('renders a label token value element', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
const expectedArgs = [tokenValueContainer];
expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('renders a milestone token value element', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(milestoneToken);
const { updateLabelTokenColorSpy, updateUserTokenAppearanceSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update user token appearance for `none` filter', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
subject.tokenType = 'none';
const { updateUserTokenAppearanceSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update user token appearance for `any` filter', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
subject.tokenType = 'any';
const { updateUserTokenAppearanceSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
});
it('does not update label token color for `none` filter', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
subject.tokenType = 'none';
const { updateLabelTokenColorSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
it('does not update label token color for `any` filter', () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
subject.tokenType = 'any';
const { updateLabelTokenColorSpy } = setupSpies(subject);
subject.render(tokenValueContainer, tokenValueElement);
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
});
});
});
......@@ -50,6 +50,29 @@ describe API::Helpers::CustomValidators do
end
end
describe API::Helpers::CustomValidators::ArrayNoneAny do
subject do
described_class.new(['test'], {}, false, scope.new)
end
context 'valid parameters' do
it 'does not raise a validation error' do
expect_no_validation_error({ 'test' => [] })
expect_no_validation_error({ 'test' => [1, 2, 3] })
expect_no_validation_error({ 'test' => 'None' })
expect_no_validation_error({ 'test' => 'Any' })
expect_no_validation_error({ 'test' => 'none' })
expect_no_validation_error({ 'test' => 'any' })
end
end
context 'invalid parameters' do
it 'should raise a validation error' do
expect_validation_error({ 'test' => 'some_other_string' })
end
end
end
def expect_no_validation_error(params)
expect { validate_test_param!(params) }.not_to raise_error
end
......
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