summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor <idrozdov@gitlab.com>2019-03-07 23:55:45 +0000
committerMike Greiling <mike@pixelcog.com>2019-03-07 23:55:45 +0000
commit9745d0de2ff605a03e7fbb95d0f71279bbd4afa5 (patch)
tree0d7f0f89b51199bf676988888539338086f419a3
parent90f4b6563d42ff7412de277682c0ecb7c25e41bb (diff)
downloadgitlab-ce-9745d0de2ff605a03e7fbb95d0f71279bbd4afa5.tar.gz
Provide EE backports for filtering by approver feature
Adds custom validator for ArrayNoneAny param Extracts some logic in js into separate files
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js16
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js133
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js122
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js151
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js125
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml1
-rw-r--r--lib/api/helpers/custom_validators.rb13
-rw-r--r--lib/api/merge_requests.rb5
-rw-r--r--spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js335
-rw-r--r--spec/javascripts/filtered_search/visual_token_value_spec.js361
-rw-r--r--spec/lib/api/helpers/custom_validators_spec.rb23
16 files changed, 713 insertions, 600 deletions
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
new file mode 100644
index 00000000000..54ea936252e
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -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);
+};
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
new file mode 100644
index 00000000000..e2f9c03ee65
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -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`;
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 57847d4ad9f..cb0a84b490b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -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'),
- },
- };
-
- 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 = {}) {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 48534bdf815..11ed85504ec 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -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);
- }
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index addf1ad94df..7746908714e 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -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');
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
new file mode 100644
index 00000000000..7f6f41c18f7
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -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;
+ })
+ );
+ }
+}
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 260484726f3..ff758fcb4fe 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -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,
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 339ce67438a..12a26fd88fa 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -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,
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index ec39db12e74..0bcca22e40f 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -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,
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index bd6f1c05949..57fbd360d46 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -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'
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index bdba47ed14d..f43be304e6b 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -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' } }
diff --git a/lib/api/helpers/custom_validators.rb b/lib/api/helpers/custom_validators.rb
index 1058f4e8a5e..c86eae6f2da 100644
--- a/lib/api/helpers/custom_validators.rb
+++ b/lib/api/helpers/custom_validators.rb
@@ -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)
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 123b7a83185..98dcc388f44 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -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
diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
index 6230da77f49..f3dc35552d5 100644
--- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
+++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js
@@ -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);
});
});
});
diff --git a/spec/javascripts/filtered_search/visual_token_value_spec.js b/spec/javascripts/filtered_search/visual_token_value_spec.js
new file mode 100644
index 00000000000..f52dc26a7bb
--- /dev/null
+++ b/spec/javascripts/filtered_search/visual_token_value_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/lib/api/helpers/custom_validators_spec.rb b/spec/lib/api/helpers/custom_validators_spec.rb
index 41e6fb47b11..9945d598a14 100644
--- a/spec/lib/api/helpers/custom_validators_spec.rb
+++ b/spec/lib/api/helpers/custom_validators_spec.rb
@@ -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