Provide EE backports for filtering by approver feature
Adds custom validator for ArrayNoneAny param Extracts some logic in js into separate files
This commit is contained in:
parent
90f4b6563d
commit
9745d0de2f
16 changed files with 713 additions and 600 deletions
|
@ -0,0 +1,16 @@
|
|||
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);
|
||||
};
|
|
@ -0,0 +1,133 @@
|
|||
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`;
|
||||
}
|
||||
}
|
|
@ -1,14 +1,9 @@
|
|||
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'),
|
||||
},
|
||||
};
|
||||
const availableMappings = new AvailableDropdownMappings(
|
||||
this.container,
|
||||
this.baseEndpoint,
|
||||
this.groupsOnly,
|
||||
this.includeAncestorGroups,
|
||||
this.includeDescendantGroups,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
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,124 +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;
|
||||
const value = tokenValue;
|
||||
|
||||
return (
|
||||
import(/* webpackChunkName: 'emoji' */ '../emoji')
|
||||
.then(Emoji => {
|
||||
Emoji.initEmojiMap()
|
||||
.then(() => {
|
||||
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(err => {
|
||||
throw err;
|
||||
});
|
||||
})
|
||||
// ignore error and leave emoji name in the search bar
|
||||
.catch(importError => {
|
||||
throw importError;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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 visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
|
||||
|
||||
const tokenType = tokenName.toLowerCase();
|
||||
|
||||
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 = {}) {
|
||||
|
@ -328,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');
|
||||
|
||||
|
|
125
app/assets/javascripts/filtered_search/visual_token_value.js
Normal file
125
app/assets/javascripts/filtered_search/visual_token_value.js
Normal file
|
@ -0,0 +1,125 @@
|
|||
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 => {
|
||||
Emoji.initEmojiMap()
|
||||
.then(() => {
|
||||
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(err => {
|
||||
throw err;
|
||||
});
|
||||
})
|
||||
// ignore error and leave emoji name in the search bar
|
||||
.catch(importError => {
|
||||
throw importError;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
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,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
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,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
%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
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
361
spec/javascripts/filtered_search/visual_token_value_spec.js
Normal file
361
spec/javascripts/filtered_search/visual_token_value_spec.js
Normal file
|
@ -0,0 +1,361 @@
|
|||
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
|
||||
|
|
Loading…
Reference in a new issue