diff options
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 |