Commit b9f35f4e authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'ce-to-ee-2018-10-03' into 'master'

CE upstream - 2018-10-03 09:21 UTC

See merge request gitlab-org/gitlab-ee!7781
parents b74defff cd5f6a8f
...@@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown { ...@@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
} }
FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
uppercaseTokenName,
});
} }
this.dismissDropdown(); this.dismissDropdown();
this.dispatchInputEvent(); this.dispatchInputEvent();
......
...@@ -143,7 +143,9 @@ export default class DropdownUtils { ...@@ -143,7 +143,9 @@ export default class DropdownUtils {
const dataValue = selected.getAttribute('data-value'); const dataValue = selected.getAttribute('data-value');
if (dataValue) { if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
});
} }
// Return boolean based on whether it was set // Return boolean based on whether it was set
......
...@@ -92,6 +92,11 @@ export default class FilteredSearchDropdownManager { ...@@ -92,6 +92,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji, gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'), element: this.container.querySelector('#js-dropdown-my-reaction'),
}, },
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
status: { status: {
reference: null, reference: null,
gl: NullDropdown, gl: NullDropdown,
...@@ -150,10 +155,16 @@ export default class FilteredSearchDropdownManager { ...@@ -150,10 +155,16 @@ export default class FilteredSearchDropdownManager {
return endpoint; return endpoint;
} }
static addWordToInput(tokenName, tokenValue = '', clicked = false) { static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
const {
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const input = FilteredSearchContainer.container.querySelector('.filtered-search');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); uppercaseTokenName,
capitalizeTokenValue,
});
input.value = ''; input.value = '';
if (clicked) { if (clicked) {
......
...@@ -428,7 +428,10 @@ export default class FilteredSearchManager { ...@@ -428,7 +428,10 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
tokens.forEach((t) => { tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`); FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
});
}); });
const fragments = searchToken.split(':'); const fragments = searchToken.split(':');
...@@ -444,7 +447,10 @@ export default class FilteredSearchManager { ...@@ -444,7 +447,10 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
} }
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey); FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(`${tokenKey}:`, ''); input.value = input.value.replace(`${tokenKey}:`, '');
} }
} else { } else {
...@@ -452,7 +458,10 @@ export default class FilteredSearchManager { ...@@ -452,7 +458,10 @@ export default class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
FilteredSearchVisualTokens.addFilterVisualToken(searchToken); const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
// Trim the last space as seen in the if statement above // Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim(); input.value = input.value.replace(searchToken, '').trim();
...@@ -503,7 +512,7 @@ export default class FilteredSearchManager { ...@@ -503,7 +512,7 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey, condition.tokenKey,
condition.value, condition.value,
canEdit, { canEdit },
); );
} else { } else {
// Sanitize value since URL converts spaces into + // Sanitize value since URL converts spaces into +
...@@ -529,10 +538,15 @@ export default class FilteredSearchManager { ...@@ -529,10 +538,15 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey, sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
); );
} else if (!match && keyParam === 'assignee_id') { } else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
...@@ -540,7 +554,7 @@ export default class FilteredSearchManager { ...@@ -540,7 +554,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const tokenName = 'assignee'; const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName); const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
} }
} else if (!match && keyParam === 'author_id') { } else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
...@@ -548,7 +562,7 @@ export default class FilteredSearchManager { ...@@ -548,7 +562,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const tokenName = 'author'; const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName); const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
} }
} else if (!match && keyParam === 'search') { } else if (!match && keyParam === 'search') {
hasFilteredSearch = true; hasFilteredSearch = true;
...@@ -584,15 +598,17 @@ export default class FilteredSearchManager { ...@@ -584,15 +598,17 @@ export default class FilteredSearchManager {
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const { tokens, searchToken } const tokenKeys = this.filteredSearchTokenKeys.getKeys();
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
const currentState = state || getParameterByName('state') || 'opened'; const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
tokens.forEach((token) => { tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase()); .searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const { param } = tokenConfig;
// Replace hyphen with underscore to use as request parameter // Replace hyphen with underscore to use as request parameter
// e.g. 'my-reaction' => 'my_reaction' // e.g. 'my-reaction' => 'my_reaction'
const underscoredKey = token.key.replace('-', '_'); const underscoredKey = token.key.replace('-', '_');
...@@ -604,6 +620,10 @@ export default class FilteredSearchManager { ...@@ -604,6 +620,10 @@ export default class FilteredSearchManager {
} else { } else {
let tokenValue = token.value; let tokenValue = token.value;
if (tokenConfig.lowercaseValueOnSubmit) {
tokenValue = tokenValue.toLowerCase();
}
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1); tokenValue = tokenValue.slice(1, tokenValue.length - 1);
......
...@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys { ...@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys {
return this.conditions; return this.conditions;
} }
shouldUppercaseTokenName(tokenKey) {
const token = this.searchByKey(tokenKey.toLowerCase());
return token && token.uppercaseTokenName;
}
shouldCapitalizeTokenValue(tokenKey) {
const token = this.searchByKey(tokenKey.toLowerCase());
return token && token.capitalizeTokenValue;
}
searchByKey(key) { searchByKey(key) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null; return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
} }
...@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys { ...@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys {
return this.conditions return this.conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null; .find(condition => condition.tokenKey === key && condition.value === value) || null;
} }
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);
}
} }
...@@ -55,12 +55,18 @@ export default class FilteredSearchVisualTokens { ...@@ -55,12 +55,18 @@ export default class FilteredSearchVisualTokens {
} }
} }
static createVisualTokenElementHTML(canEdit = true) { static createVisualTokenElementHTML(options = {}) {
const {
canEdit = true,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return ` return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> <div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div> <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
<div class="value-container"> <div class="value-container">
<div class="value"></div> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button"> <div class="remove-token" role="button">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</div> </div>
...@@ -182,16 +188,26 @@ export default class FilteredSearchVisualTokens { ...@@ -182,16 +188,26 @@ export default class FilteredSearchVisualTokens {
} }
} }
static addVisualTokenElement(name, value, isSearchTerm, canEdit) { static addVisualTokenElement(name, value, options = {}) {
const {
isSearchTerm = false,
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
} = options;
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('js-visual-token'); li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) { if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else { } else {
li.innerHTML = '<div class="name"></div>'; li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
} }
li.querySelector('.name').innerText = name; li.querySelector('.name').innerText = name;
...@@ -212,20 +228,32 @@ export default class FilteredSearchVisualTokens { ...@@ -212,20 +228,32 @@ export default class FilteredSearchVisualTokens {
} }
} }
static addFilterVisualToken(tokenName, tokenValue, canEdit) { static addFilterVisualToken(tokenName, tokenValue, {
canEdit,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = {}) {
const { lastVisualToken, isLastVisualTokenValid } const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { addVisualTokenElement } = FilteredSearchVisualTokens; const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, false, canEdit); addVisualTokenElement(tokenName, tokenValue, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} else { } else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText; const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken); tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName; const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, false, canEdit); addVisualTokenElement(previousTokenName, value, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} }
} }
...@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens { ...@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else { } else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true); FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
isSearchTerm: true,
});
} }
} }
...@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens { ...@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens {
let value; let value;
if (token.classList.contains('filtered-search-token')) { if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, {
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
});
const valueContainerElement = token.querySelector('.value-container'); const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue; value = valueContainerElement.dataset.originalValue;
......
...@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered ...@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true, isGroupDecendent: true,
......
...@@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants'; ...@@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new
......
...@@ -51,10 +51,10 @@ export default { ...@@ -51,10 +51,10 @@ export default {
<template> <template>
<div class="block"> <div class="block">
<issuable-time-tracker <issuable-time-tracker
:time_estimate="store.timeEstimate" :time-estimate="store.timeEstimate"
:time_spent="store.totalTimeSpent" :time-spent="store.totalTimeSpent"
:human_time_estimate="store.humanTimeEstimate" :human-time-estimate="store.humanTimeEstimate"
:human_time_spent="store.humanTotalTimeSpent" :human-time-spent="store.humanTotalTimeSpent"
:root-path="store.rootPath" :root-path="store.rootPath"
/> />
</div> </div>
......
...@@ -19,24 +19,20 @@ export default { ...@@ -19,24 +19,20 @@ export default {
TimeTrackingHelpState, TimeTrackingHelpState,
}, },
props: { props: {
// eslint-disable-next-line vue/prop-name-casing timeEstimate: {
time_estimate: {
type: Number, type: Number,
required: true, required: true,
}, },
// eslint-disable-next-line vue/prop-name-casing timeSpent: {
time_spent: {
type: Number, type: Number,
required: true, required: true,
}, },
// eslint-disable-next-line vue/prop-name-casing humanTimeEstimate: {
human_time_estimate: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
// eslint-disable-next-line vue/prop-name-casing humanTimeSpent: {
human_time_spent: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
...@@ -52,18 +48,6 @@ export default { ...@@ -52,18 +48,6 @@ export default {
}; };
}, },
computed: { computed: {
timeSpent() {
return this.time_spent;
},
timeEstimate() {
return this.time_estimate;
},
timeEstimateHumanReadable() {
return this.human_time_estimate;
},
timeSpentHumanReadable() {
return this.human_time_spent;
},
hasTimeSpent() { hasTimeSpent() {
return !!this.timeSpent; return !!this.timeSpent;
}, },
...@@ -94,10 +78,12 @@ export default { ...@@ -94,10 +78,12 @@ export default {
this.showHelp = show; this.showHelp = show;
}, },
update(data) { update(data) {
this.time_estimate = data.time_estimate; const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data;
this.time_spent = data.time_spent;
this.human_time_estimate = data.human_time_estimate; this.timeEstimate = timeEstimate;
this.human_time_spent = data.human_time_spent; this.timeSpent = timeSpent;
this.humanTimeEstimate = humanTimeEstimate;
this.humanTimeSpent = humanTimeSpent;
}, },
}, },
}; };
...@@ -114,8 +100,8 @@ export default { ...@@ -114,8 +100,8 @@ export default {
:show-help-state="showHelpState" :show-help-state="showHelpState"
:show-spent-only-state="showSpentOnlyState" :show-spent-only-state="showSpentOnlyState"
:show-estimate-only-state="showEstimateOnlyState" :show-estimate-only-state="showEstimateOnlyState"
:time-spent-human-readable="timeSpentHumanReadable" :time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="timeEstimateHumanReadable" :time-estimate-human-readable="humanTimeEstimate"
/> />
<div class="title hide-collapsed"> <div class="title hide-collapsed">
{{ __('Time tracking') }} {{ __('Time tracking') }}
...@@ -145,11 +131,11 @@ export default { ...@@ -145,11 +131,11 @@ export default {
<div class="time-tracking-content hide-collapsed"> <div class="time-tracking-content hide-collapsed">
<time-tracking-estimate-only-pane <time-tracking-estimate-only-pane
v-if="showEstimateOnlyState" v-if="showEstimateOnlyState"
:time-estimate-human-readable="timeEstimateHumanReadable" :time-estimate-human-readable="humanTimeEstimate"
/> />
<time-tracking-spent-only-pane <time-tracking-spent-only-pane
v-if="showSpentOnlyState" v-if="showSpentOnlyState"
:time-spent-human-readable="timeSpentHumanReadable" :time-spent-human-readable="humanTimeSpent"
/> />
<time-tracking-no-tracking-pane <time-tracking-no-tracking-pane
v-if="showNoTimeTrackingState" v-if="showNoTimeTrackingState"
...@@ -158,8 +144,8 @@ export default { ...@@ -158,8 +144,8 @@ export default {
v-if="showComparisonState" v-if="showComparisonState"
:time-estimate="timeEstimate" :time-estimate="timeEstimate"
:time-spent="timeSpent" :time-spent="timeSpent"
:time-spent-human-readable="timeSpentHumanReadable" :time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="timeEstimateHumanReadable" :time-estimate-human-readable="humanTimeEstimate"
/> />
<transition name="help-state-toggle"> <transition name="help-state-toggle">
<time-tracking-help-state <time-tracking-help-state
......
...@@ -7,6 +7,8 @@ export default class SidebarMilestone { ...@@ -7,6 +7,8 @@ export default class SidebarMilestone {
if (!el) return; if (!el) return;
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -15,10 +17,10 @@ export default class SidebarMilestone { ...@@ -15,10 +17,10 @@ export default class SidebarMilestone {
}, },
render: createElement => createElement('timeTracker', { render: createElement => createElement('timeTracker', {
props: { props: {
time_estimate: parseInt(el.dataset.timeEstimate, 10), timeEstimate: parseInt(timeEstimate, 10),
time_spent: parseInt(el.dataset.timeSpent, 10), timeSpent: parseInt(timeSpent, 10),
human_time_estimate: el.dataset.humanTimeEstimate, humanTimeEstimate,
human_time_spent: el.dataset.humanTimeSpent, humanTimeSpent,
rootPath: '/', rootPath: '/',
}, },
}), }),
......
...@@ -223,6 +223,7 @@ ...@@ -223,6 +223,7 @@
} }
} }
.clipboard-group,
.commit-sha-group { .commit-sha-group {
display: inline-flex; display: inline-flex;
......
...@@ -27,13 +27,17 @@ ...@@ -27,13 +27,17 @@
# updated_before: datetime # updated_before: datetime
# #
class MergeRequestsFinder < IssuableFinder class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
end
def klass def klass
MergeRequest MergeRequest
end end
def filter_items(_items) def filter_items(_items)
items = by_source_branch(super) items = by_source_branch(super)
items = by_wip(items)
by_target_branch(items) by_target_branch(items)
end end
...@@ -61,5 +65,20 @@ class MergeRequestsFinder < IssuableFinder ...@@ -61,5 +65,20 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch) items.where(target_branch: target_branch)
end end
# rubocop: enable CodeReuse/ActiveRecord
def by_wip(items)
if params[:wip] == 'yes'
items.where(wip_match(items.arel_table))
elsif params[:wip] == 'no'
items.where.not(wip_match(items.arel_table))
else
items
end
end
def wip_match(table)
table[:title].matches('WIP:%')
.or(table[:title].matches('WIP %'))
.or(table[:title].matches('[WIP]%'))
end
end end
...@@ -265,7 +265,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -265,7 +265,7 @@ class MergeRequest < ActiveRecord::Base
end end
end end
WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze WIP_REGEX = /\A*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
def self.work_in_progress?(title) def self.work_in_progress?(title)
!!(title =~ WIP_REGEX) !!(title =~ WIP_REGEX)
......
- page_title @application.name, "Applications" - page_title @application.name, "Applications"
%h3.page-title %h3.page-title
Application: #{@application.name} Application: #{@application.name}
...@@ -6,23 +7,29 @@ ...@@ -6,23 +7,29 @@
%table.table %table.table
%tr %tr
%td %td
Application Id = _('Application ID')
%td %td
%code#application_id= @application.uid .clipboard-group
.input-group
%input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr %tr
%td %td
Secret: = _('Secret')
%td %td
%code#secret= @application.secret .clipboard-group
.input-group
%input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr %tr
%td %td
Callback url = _('Callback URL')
%td %td
- @application.redirect_uri.split.each do |uri| - @application.redirect_uri.split.each do |uri|
%div %div
%span.monospace= uri %span.monospace= uri
%tr %tr
%td %td
Trusted Trusted
......
...@@ -10,18 +10,25 @@ ...@@ -10,18 +10,25 @@
%table.table %table.table
%tr %tr
%td %td
= _('Application Id') = _('Application ID')
%td %td
%code#application_id= @application.uid .clipboard-group
.input-group
%input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr %tr
%td %td
= _('Secret:') = _('Secret')
%td %td
%code#secret= @application.secret .clipboard-group
.input-group
%input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr %tr
%td %td
= _('Callback url') = _('Callback URL')
%td %td
- @application.redirect_uri.split.each do |uri| - @application.redirect_uri.split.each do |uri|
%div %div
......
...@@ -33,13 +33,13 @@ ...@@ -33,13 +33,13 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } } %li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
= sprite_icon('search') = sprite_icon('search')
%span %span
Press Enter or click to search Press Enter or click to search
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass -# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue -# haml lint's ClassAttributeWithStaticValue
%svg %svg
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Assignee No Assignee
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
- if current_user - if current_user
...@@ -73,38 +73,46 @@ ...@@ -73,38 +73,46 @@
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Milestone No Milestone
%li.filter-dropdown-item{ data: { value: 'upcoming' } } %li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
Upcoming Upcoming
%li.filter-dropdown-item{ 'data-value' => 'started' } %li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
Started Started
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.js-data-value %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}} {{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Label No Label
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' } %span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value %span.label-title.js-data-value
{{title}} {{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
%gl-emoji %gl-emoji
%span.js-data-value.prepend-left-10 %span.js-data-value.prepend-left-10
{{name}} {{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
= render_if_exists 'shared/issuable/filter_weight', type: type = render_if_exists 'shared/issuable/filter_weight', type: type
......
---
title: Add copy to clipboard button for application id and secret
merge_request: 21978
author: George Tsiolis
type: other
---
title: Added search functionality for Work In Progress (WIP) merge requests
merge_request: 18119
author: Chantal Rollison
type: added
...@@ -47,6 +47,7 @@ Parameters: ...@@ -47,6 +47,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch | | `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch | | `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` | | `search` | string | no | Search merge requests against their `title` and `description` |
| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json ```json
[ [
......
...@@ -7,7 +7,7 @@ have been marked a **Work In Progress**. ...@@ -7,7 +7,7 @@ have been marked a **Work In Progress**.
![Blocked Accept Button](img/wip_blocked_accept_button.png) ![Blocked Accept Button](img/wip_blocked_accept_button.png)
To mark a merge request a Work In Progress, simply start its title with `[WIP]` To mark a merge request a Work In Progress, simply start its title with `[WIP]`
or `WIP:`. As an alternative, you're also able to do it by sending a commit or `WIP:`. As an alternative, you're also able to do it by sending a commit
with its title starting with `wip` or `WIP` to the merge request's source branch. with its title starting with `wip` or `WIP` to the merge request's source branch.
![Mark as WIP](img/wip_mark_as_wip.png) ![Mark as WIP](img/wip_mark_as_wip.png)
...@@ -15,4 +15,11 @@ with its title starting with `wip` or `WIP` to the merge request's source branch ...@@ -15,4 +15,11 @@ with its title starting with `wip` or `WIP` to the merge request's source branch
To allow a Work In Progress merge request to be accepted again when it's ready, To allow a Work In Progress merge request to be accepted again when it's ready,
simply remove the `WIP` prefix. simply remove the `WIP` prefix.
![Unark as WIP](img/wip_unmark_as_wip.png) ![Unmark as WIP](img/wip_unmark_as_wip.png)
## Filtering merge requests with WIP Status
To filter merge requests with the `WIP` status, you can type `wip`
and select the value for your filter from the merge request search input.
![Filter WIP MRs](img/filter_wip_merge_requests.png)
...@@ -35,7 +35,6 @@ module API ...@@ -35,7 +35,6 @@ module API
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def find_merge_requests(args = {}) def find_merge_requests(args = {})
args = declared_params.merge(args) args = declared_params.merge(args)
args[:milestone_title] = args.delete(:milestone) args[:milestone_title] = args.delete(:milestone)
args[:label_name] = args.delete(:labels) args[:label_name] = args.delete(:labels)
args[:scope] = args[:scope].underscore if args[:scope] args[:scope] = args[:scope].underscore if args[:scope]
...@@ -99,6 +98,7 @@ module API ...@@ -99,6 +98,7 @@ module API
optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' optional :source_branch, type: String, desc: 'Return merge requests with the given source branch'
optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' optional :target_branch, type: String, desc: 'Return merge requests with the given target branch'
optional :search, type: String, desc: 'Search merge requests for text present in the title or description' optional :search, type: String, desc: 'Search merge requests for text present in the title or description'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :pagination use :pagination
end end
end end
......
...@@ -703,7 +703,7 @@ msgstr "" ...@@ -703,7 +703,7 @@ msgstr ""
msgid "Application" msgid "Application"
msgstr "" msgstr ""
msgid "Application Id" msgid "Application ID"
msgstr "" msgstr ""
msgid "Application: %{name}" msgid "Application: %{name}"
...@@ -1320,9 +1320,6 @@ msgstr "" ...@@ -1320,9 +1320,6 @@ msgstr ""
msgid "Callback URL" msgid "Callback URL"
msgstr "" msgstr ""
msgid "Callback url"
msgstr ""
msgid "Can't find HEAD commit for this branch" msgid "Can't find HEAD commit for this branch"
msgstr "" msgstr ""
...@@ -2233,6 +2230,9 @@ msgstr "" ...@@ -2233,6 +2230,9 @@ msgstr ""
msgid "Copy HTTPS clone URL" msgid "Copy HTTPS clone URL"
msgstr "" msgstr ""
msgid "Copy ID to clipboard"
msgstr ""
msgid "Copy SSH clone URL" msgid "Copy SSH clone URL"
msgstr "" msgstr ""
...@@ -2260,6 +2260,9 @@ msgstr "" ...@@ -2260,6 +2260,9 @@ msgstr ""
msgid "Copy reference to clipboard" msgid "Copy reference to clipboard"
msgstr "" msgstr ""
msgid "Copy secret to clipboard"
msgstr ""
msgid "Copy to clipboard" msgid "Copy to clipboard"
msgstr "" msgstr ""
...@@ -6694,7 +6697,7 @@ msgstr "" ...@@ -6694,7 +6697,7 @@ msgstr ""
msgid "Seconds to wait for a storage access attempt" msgid "Seconds to wait for a storage access attempt"
msgstr "" msgstr ""
msgid "Secret:" msgid "Secret"
msgstr "" msgstr ""
msgid "Security" msgid "Security"
......
...@@ -16,7 +16,7 @@ RSpec.describe 'admin manage applications' do ...@@ -16,7 +16,7 @@ RSpec.describe 'admin manage applications' do
check :doorkeeper_application_trusted check :doorkeeper_application_trusted
click_on 'Submit' click_on 'Submit'
expect(page).to have_content('Application: test') expect(page).to have_content('Application: test')
expect(page).to have_content('Application Id') expect(page).to have_content('Application ID')
expect(page).to have_content('Secret') expect(page).to have_content('Secret')
expect(page).to have_content('Trusted Y') expect(page).to have_content('Trusted Y')
...@@ -28,7 +28,7 @@ RSpec.describe 'admin manage applications' do ...@@ -28,7 +28,7 @@ RSpec.describe 'admin manage applications' do
click_on 'Submit' click_on 'Submit'
expect(page).to have_content('test_changed') expect(page).to have_content('test_changed')
expect(page).to have_content('Application Id') expect(page).to have_content('Application ID')
expect(page).to have_content('Secret') expect(page).to have_content('Secret')
expect(page).to have_content('Trusted N') expect(page).to have_content('Trusted N')
......
...@@ -15,6 +15,7 @@ describe 'Dropdown hint', :js do ...@@ -15,6 +15,7 @@ describe 'Dropdown hint', :js do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
create(:issue, project: project) create(:issue, project: project)
create(:merge_request, source_project: project, target_project: project)
end end
context 'when user not logged in' do context 'when user not logged in' do
...@@ -224,4 +225,21 @@ describe 'Dropdown hint', :js do ...@@ -224,4 +225,21 @@ describe 'Dropdown hint', :js do
end end
end end
end end
context 'merge request page' do
before do
sign_in(user)
visit project_merge_requests_path(project)
filtered_search.click
end
it 'shows the WIP menu item and opens the WIP options dropdown' do
click_hint('wip')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css('#js-dropdown-wip', visible: true)
expect_tokens([{ name: 'wip' }])
expect_filtered_search_input_empty
end
end
end end
...@@ -16,7 +16,7 @@ describe 'User manages applications' do ...@@ -16,7 +16,7 @@ describe 'User manages applications' do
click_on 'Save application' click_on 'Save application'
expect(page).to have_content 'Application: test' expect(page).to have_content 'Application: test'
expect(page).to have_content 'Application Id' expect(page).to have_content 'Application ID'
expect(page).to have_content 'Secret' expect(page).to have_content 'Secret'
click_on 'Edit' click_on 'Edit'
...@@ -26,7 +26,7 @@ describe 'User manages applications' do ...@@ -26,7 +26,7 @@ describe 'User manages applications' do
click_on 'Save application' click_on 'Save application'
expect(page).to have_content 'test_changed' expect(page).to have_content 'test_changed'
expect(page).to have_content 'Application Id' expect(page).to have_content 'Application ID'
expect(page).to have_content 'Secret' expect(page).to have_content 'Secret'
visit applications_profile_path visit applications_profile_path
......
...@@ -3,25 +3,47 @@ require 'spec_helper' ...@@ -3,25 +3,47 @@ require 'spec_helper'
describe MergeRequestsFinder do describe MergeRequestsFinder do
include ProjectForksHelper include ProjectForksHelper
# We need to explicitly permit Gitaly N+1s because of the specs that use
# :request_store. Gitaly N+1 detection is only enabled when :request_store is,
# but we don't care about potential N+1s when we're just creating several
# projects in the setup phase.
def create_project_without_n_plus_1(*args)
Gitlab::GitalyClient.allow_n_plus_1_calls do
create(:project, :public, *args)
end
end
let(:user) { create :user } let(:user) { create :user }
let(:user2) { create :user } let(:user2) { create :user }
let(:group) { create(:group) } let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) } let(:subgroup) { create(:group, parent: group) }
let(:project1) { create(:project, :public, group: group) } let(:project1) { create_project_without_n_plus_1(group: group) }
let(:project2) { fork_project(project1, user) } let(:project2) do
Gitlab::GitalyClient.allow_n_plus_1_calls do
fork_project(project1, user)
end
end
let(:project3) do let(:project3) do
p = fork_project(project1, user) Gitlab::GitalyClient.allow_n_plus_1_calls do
p.update!(archived: true) p = fork_project(project1, user)
p p.update!(archived: true)
p
end
end end
let(:project4) { create(:project, :public, group: subgroup) } let(:project4) { create_project_without_n_plus_1(group: subgroup) }
let(:project5) { create_project_without_n_plus_1(group: subgroup) }
let(:project6) { create_project_without_n_plus_1(group: subgroup) }
let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) } let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) }
let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') } let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') }
let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked') } let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked', title: 'thing WIP thing') }
let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) } let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3, title: 'WIP thing') }
let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) } let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4, title: '[WIP]') }
let!(:merge_request6) { create(:merge_request, :simple, author: user, source_project: project5, target_project: project5, title: 'WIP: thing') }
let!(:merge_request7) { create(:merge_request, :simple, author: user, source_project: project6, target_project: project6, title: 'wip thing') }
let!(:merge_request8) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project1, title: '[wip] thing') }
let!(:merge_request9) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project2, title: 'wip: thing') }
before do before do
project1.add_maintainer(user) project1.add_maintainer(user)
...@@ -29,25 +51,27 @@ describe MergeRequestsFinder do ...@@ -29,25 +51,27 @@ describe MergeRequestsFinder do
project3.add_developer(user) project3.add_developer(user)
project2.add_developer(user2) project2.add_developer(user2)
project4.add_developer(user) project4.add_developer(user)
project5.add_developer(user)
project6.add_developer(user)
end end
describe "#execute" do describe "#execute" do
it 'filters by scope' do it 'filters by scope' do
params = { scope: 'authored', state: 'opened' } params = { scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3) expect(merge_requests.size).to eq(7)
end end
it 'filters by project' do it 'filters by project' do
params = { project_id: project1.id, scope: 'authored', state: 'opened' } params = { project_id: project1.id, scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1) expect(merge_requests.size).to eq(2)
end end
it 'ignores sorting by weight' do it 'ignores sorting by weight' do
params = { project_id: project1.id, scope: 'authored', state: 'opened', weight: Issue::WEIGHT_ANY } params = { project_id: project1.id, scope: 'authored', state: 'opened', weight: Issue::WEIGHT_ANY }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1) expect(merge_requests.size).to eq(2)
end end
it 'filters by group' do it 'filters by group' do
...@@ -55,7 +79,7 @@ describe MergeRequestsFinder do ...@@ -55,7 +79,7 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(2) expect(merge_requests.size).to eq(3)
end end
it 'filters by group including subgroups', :nested_groups do it 'filters by group including subgroups', :nested_groups do
...@@ -63,13 +87,13 @@ describe MergeRequestsFinder do ...@@ -63,13 +87,13 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3) expect(merge_requests.size).to eq(6)
end end
it 'filters by non_archived' do it 'filters by non_archived' do
params = { non_archived: true } params = { non_archived: true }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(4) expect(merge_requests.size).to eq(8)
end end
it 'filters by iid' do it 'filters by iid' do
...@@ -104,6 +128,36 @@ describe MergeRequestsFinder do ...@@ -104,6 +128,36 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request3) expect(merge_requests).to contain_exactly(merge_request3)
end end
it 'filters by wip' do
params = { wip: 'yes' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request4, merge_request5, merge_request6, merge_request7, merge_request8, merge_request9)
end
it 'filters by not wip' do
params = { wip: 'no' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3)
end
it 'returns all items if no valid wip param exists' do
params = { wip: '' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5, merge_request6, merge_request7, merge_request8, merge_request9)
end
it 'adds wip to scalar params' do
scalar_params = described_class.scalar_params
expect(scalar_params).to include(:wip, :assignee_id)
end
context 'filtering by group milestone' do context 'filtering by group milestone' do
let!(:group) { create(:group, :public) } let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) } let(:group_milestone) { create(:milestone, group: group) }
...@@ -213,7 +267,7 @@ describe MergeRequestsFinder do ...@@ -213,7 +267,7 @@ describe MergeRequestsFinder do
it 'returns the number of rows for the default state' do it 'returns the number of rows for the default state' do
finder = described_class.new(user) finder = described_class.new(user)
expect(finder.row_count).to eq(3) expect(finder.row_count).to eq(7)
end end
it 'returns the number of rows for a given state' do it 'returns the number of rows for a given state' do
......
...@@ -288,13 +288,13 @@ describe('Dropdown Utils', () => { ...@@ -288,13 +288,13 @@ describe('Dropdown Utils', () => {
describe('setDataValueIfSelected', () => { describe('setDataValueIfSelected', () => {
beforeEach(() => { beforeEach(() => {
spyOn(FilteredSearchDropdownManager, 'addWordToInput') spyOn(FilteredSearchDropdownManager, 'addWordToInput').and.callFake(() => {});
.and.callFake(() => {});
}); });
it('calls addWordToInput when dataValue exists', () => { it('calls addWordToInput when dataValue exists', () => {
const selected = { const selected = {
getAttribute: () => 'value', getAttribute: () => 'value',
hasAttribute: () => false,
}; };
DropdownUtils.setDataValueIfSelected(null, selected); DropdownUtils.setDataValueIfSelected(null, selected);
...@@ -304,6 +304,7 @@ describe('Dropdown Utils', () => { ...@@ -304,6 +304,7 @@ describe('Dropdown Utils', () => {
it('returns true when dataValue exists', () => { it('returns true when dataValue exists', () => {
const selected = { const selected = {
getAttribute: () => 'value', getAttribute: () => 'value',
hasAttribute: () => false,
}; };
const result = DropdownUtils.setDataValueIfSelected(null, selected); const result = DropdownUtils.setDataValueIfSelected(null, selected);
......
...@@ -240,13 +240,17 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -240,13 +240,17 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div class="test-area"> <div class="test-area">
${subject.createVisualTokenElementHTML()} ${subject.createVisualTokenElementHTML('custom-token')}
</div> </div>
`); `);
tokenElement = document.querySelector('.test-area').firstElementChild; tokenElement = document.querySelector('.test-area').firstElementChild;
}); });
it('should add class name to token element', () => {
expect(document.querySelector('.test-area .custom-token')).toBeDefined();
});
it('contains name div', () => { it('contains name div', () => {
expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything()); expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything());
}); });
...@@ -280,7 +284,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -280,7 +284,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => { describe('addVisualTokenElement', () => {
it('renders search visual tokens', () => { it('renders search visual tokens', () => {
subject.addVisualTokenElement('search term', null, true); subject.addVisualTokenElement('search term', null, { isSearchTerm: true });
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.classList.contains('filtered-search-term')).toEqual(true);
......
...@@ -8,7 +8,10 @@ describe('Issuable Time Tracker', () => { ...@@ -8,7 +8,10 @@ describe('Issuable Time Tracker', () => {
let initialData; let initialData;
let vm; let vm;
const initTimeTrackingComponent = opts => { const initTimeTrackingComponent = ({ timeEstimate,
timeSpent,
timeEstimateHumanReadable,
timeSpentHumanReadable }) => {
setFixtures(` setFixtures(`
<div> <div>
<div id="mock-container"></div> <div id="mock-container"></div>
...@@ -16,10 +19,10 @@ describe('Issuable Time Tracker', () => { ...@@ -16,10 +19,10 @@ describe('Issuable Time Tracker', () => {
`); `);
initialData = { initialData = {
time_estimate: opts.timeEstimate, timeEstimate,
time_spent: opts.timeSpent, timeSpent,
human_time_estimate: opts.timeEstimateHumanReadable, humanTimeEstimate: timeEstimateHumanReadable,
human_time_spent: opts.timeSpentHumanReadable, humanTimeSpent: timeSpentHumanReadable,
rootPath: '/', rootPath: '/',
}; };
...@@ -43,8 +46,8 @@ describe('Issuable Time Tracker', () => { ...@@ -43,8 +46,8 @@ describe('Issuable Time Tracker', () => {
describe('Initialization', () => { describe('Initialization', () => {
beforeEach(() => { beforeEach(() => {
initTimeTrackingComponent({ initTimeTrackingComponent({
timeEstimate: 100000, timeEstimate: 10000, // 2h 46m
timeSpent: 5000, timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '2h 46m', timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '1h 23m', timeSpentHumanReadable: '1h 23m',
}); });
...@@ -56,14 +59,14 @@ describe('Issuable Time Tracker', () => { ...@@ -56,14 +59,14 @@ describe('Issuable Time Tracker', () => {
it('should correctly set timeEstimate', done => { it('should correctly set timeEstimate', done => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.timeEstimate).toBe(initialData.time_estimate); expect(vm.timeEstimate).toBe(initialData.timeEstimate);
done(); done();
}); });
}); });
it('should correctly set time_spent', done => { it('should correctly set time_spent', done => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.timeSpent).toBe(initialData.time_spent); expect(vm.timeSpent).toBe(initialData.timeSpent);
done(); done();
}); });
}); });
...@@ -74,8 +77,8 @@ describe('Issuable Time Tracker', () => { ...@@ -74,8 +77,8 @@ describe('Issuable Time Tracker', () => {
describe('Comparison pane', () => { describe('Comparison pane', () => {
beforeEach(() => { beforeEach(() => {
initTimeTrackingComponent({ initTimeTrackingComponent({
timeEstimate: 100000, timeEstimate: 100000, // 1d 3h
timeSpent: 5000, timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '', timeEstimateHumanReadable: '',
timeSpentHumanReadable: '', timeSpentHumanReadable: '',
}); });
...@@ -106,8 +109,8 @@ describe('Issuable Time Tracker', () => { ...@@ -106,8 +109,8 @@ describe('Issuable Time Tracker', () => {
}); });
it('should display the remaining meter with the correct background color when over estimate', done => { it('should display the remaining meter with the correct background color when over estimate', done => {
vm.time_estimate = 100000; vm.timeEstimate = 10000; // 2h 46m
vm.time_spent = 20000000; vm.timeSpent = 20000000; // 231 days
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]')).not.toBeNull(); expect(vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]')).not.toBeNull();
done(); done();
...@@ -119,7 +122,7 @@ describe('Issuable Time Tracker', () => { ...@@ -119,7 +122,7 @@ describe('Issuable Time Tracker', () => {
describe('Estimate only pane', () => { describe('Estimate only pane', () => {
beforeEach(() => { beforeEach(() => {
initTimeTrackingComponent({ initTimeTrackingComponent({
timeEstimate: 100000, timeEstimate: 10000, // 2h 46m
timeSpent: 0, timeSpent: 0,
timeEstimateHumanReadable: '2h 46m', timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '', timeSpentHumanReadable: '',
...@@ -142,7 +145,7 @@ describe('Issuable Time Tracker', () => { ...@@ -142,7 +145,7 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => { beforeEach(() => {
initTimeTrackingComponent({ initTimeTrackingComponent({
timeEstimate: 0, timeEstimate: 0,
timeSpent: 5000, timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '2h 46m', timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '1h 23m', timeSpentHumanReadable: '1h 23m',
}); });
......
...@@ -747,7 +747,7 @@ describe MergeRequest do ...@@ -747,7 +747,7 @@ describe MergeRequest do
end end
describe "#wipless_title" do describe "#wipless_title" do
['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', ' [WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix| ['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', '[WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix|
it "removes the '#{wip_prefix}' prefix" do it "removes the '#{wip_prefix}' prefix" do
wipless_title = subject.title wipless_title = subject.title
subject.title = "#{wip_prefix}#{subject.title}" subject.title = "#{wip_prefix}#{subject.title}"
......
...@@ -81,6 +81,35 @@ describe API::MergeRequests do ...@@ -81,6 +81,35 @@ describe API::MergeRequests do
let(:user2) { create(:user) } let(:user2) { create(:user) }
it 'returns an array of all merge requests except unauthorized ones' do it 'returns an array of all merge requests except unauthorized ones' do
get api('/merge_requests', user), scope: :all
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |mr| mr['id'] })
.to contain_exactly(merge_request.id, merge_request_closed.id, merge_request_merged.id, merge_request_locked.id, merge_request2.id)
end
it "returns an array of no merge_requests when wip=yes" do
get api("/merge_requests", user), wip: 'yes'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
it "returns an array of no merge_requests when wip=no" do
get api("/merge_requests", user), wip: 'no'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |mr| mr['id'] })
.to contain_exactly(merge_request.id, merge_request_closed.id, merge_request_merged.id, merge_request_locked.id, merge_request2.id)
end
it 'does not return unauthorized merge requests' do
private_project = create(:project, :private) private_project = create(:project, :private)
merge_request3 = create(:merge_request, :simple, source_project: private_project, target_project: private_project, source_branch: 'other-branch') merge_request3 = create(:merge_request, :simple, source_project: private_project, target_project: private_project, source_branch: 'other-branch')
...@@ -244,6 +273,15 @@ describe API::MergeRequests do ...@@ -244,6 +273,15 @@ describe API::MergeRequests do
expect(response).to have_gitlab_http_status(404) expect(response).to have_gitlab_http_status(404)
end end
it "returns an array of no merge_requests when wip=yes" do
get api("/projects/#{project.id}/merge_requests", user), wip: 'yes'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
it 'returns merge_request by "iids" array' do it 'returns merge_request by "iids" array' do
get api(endpoint_path, user), iids: [merge_request.iid, merge_request_closed.iid] get api(endpoint_path, user), iids: [merge_request.iid, merge_request_closed.iid]
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment