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 {
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.dispatchInputEvent();
......
......@@ -143,7 +143,9 @@ export default class DropdownUtils {
const dataValue = selected.getAttribute('data-value');
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
......
......@@ -92,6 +92,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
status: {
reference: null,
gl: NullDropdown,
......@@ -150,10 +155,16 @@ export default class FilteredSearchDropdownManager {
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');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
input.value = '';
if (clicked) {
......
......@@ -428,7 +428,10 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
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(':');
......@@ -444,7 +447,10 @@ export default class FilteredSearchManager {
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}:`, '');
}
} else {
......@@ -452,7 +458,10 @@ export default class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
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
input.value = input.value.replace(searchToken, '').trim();
......@@ -503,7 +512,7 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.value,
canEdit,
{ canEdit },
);
} else {
// Sanitize value since URL converts spaces into +
......@@ -529,10 +538,15 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit,
{
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
);
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
......@@ -540,7 +554,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
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') {
const id = parseInt(value, 10);
......@@ -548,7 +562,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
......@@ -584,15 +598,17 @@ export default class FilteredSearchManager {
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys
.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
// e.g. 'my-reaction' => 'my_reaction'
const underscoredKey = token.key.replace('-', '_');
......@@ -604,6 +620,10 @@ export default class FilteredSearchManager {
} else {
let tokenValue = token.value;
if (tokenConfig.lowercaseValueOnSubmit) {
tokenValue = tokenValue.toLowerCase();
}
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
......
......@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys {
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) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
......@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys {
return this.conditions
.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 {
}
}
static createVisualTokenElementHTML(canEdit = true) {
static createVisualTokenElementHTML(options = {}) {
const {
canEdit = true,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return `
<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"></div>
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
......@@ -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');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
}
li.querySelector('.name').innerText = name;
......@@ -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 }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, false, canEdit);
addVisualTokenElement(tokenName, tokenValue, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, false, canEdit);
addVisualTokenElement(previousTokenName, value, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
}
}
......@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true);
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
isSearchTerm: true,
});
}
}
......@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens {
let value;
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');
value = valueContainerElement.dataset.originalValue;
......
......@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true,
......
......@@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
......
......@@ -51,10 +51,10 @@ export default {
<template>
<div class="block">
<issuable-time-tracker
:time_estimate="store.timeEstimate"
:time_spent="store.totalTimeSpent"
:human_time_estimate="store.humanTimeEstimate"
:human_time_spent="store.humanTotalTimeSpent"
:time-estimate="store.timeEstimate"
:time-spent="store.totalTimeSpent"
:human-time-estimate="store.humanTimeEstimate"
:human-time-spent="store.humanTotalTimeSpent"
:root-path="store.rootPath"
/>
</div>
......
......@@ -19,24 +19,20 @@ export default {
TimeTrackingHelpState,
},
props: {
// eslint-disable-next-line vue/prop-name-casing
time_estimate: {
timeEstimate: {
type: Number,
required: true,
},
// eslint-disable-next-line vue/prop-name-casing
time_spent: {
timeSpent: {
type: Number,
required: true,
},
// eslint-disable-next-line vue/prop-name-casing
human_time_estimate: {
humanTimeEstimate: {
type: String,
required: false,
default: '',
},
// eslint-disable-next-line vue/prop-name-casing
human_time_spent: {
humanTimeSpent: {
type: String,
required: false,
default: '',
......@@ -52,18 +48,6 @@ export default {
};
},
computed: {
timeSpent() {
return this.time_spent;
},
timeEstimate() {
return this.time_estimate;
},
timeEstimateHumanReadable() {
return this.human_time_estimate;
},
timeSpentHumanReadable() {
return this.human_time_spent;
},
hasTimeSpent() {
return !!this.timeSpent;
},
......@@ -94,10 +78,12 @@ export default {
this.showHelp = show;
},
update(data) {
this.time_estimate = data.time_estimate;
this.time_spent = data.time_spent;
this.human_time_estimate = data.human_time_estimate;
this.human_time_spent = data.human_time_spent;
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data;
this.timeEstimate = timeEstimate;
this.timeSpent = timeSpent;
this.humanTimeEstimate = humanTimeEstimate;
this.humanTimeSpent = humanTimeSpent;
},
},
};
......@@ -114,8 +100,8 @@ export default {
:show-help-state="showHelpState"
:show-spent-only-state="showSpentOnlyState"
:show-estimate-only-state="showEstimateOnlyState"
:time-spent-human-readable="timeSpentHumanReadable"
:time-estimate-human-readable="timeEstimateHumanReadable"
:time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
<div class="title hide-collapsed">
{{ __('Time tracking') }}
......@@ -145,11 +131,11 @@ export default {
<div class="time-tracking-content hide-collapsed">
<time-tracking-estimate-only-pane
v-if="showEstimateOnlyState"
:time-estimate-human-readable="timeEstimateHumanReadable"
:time-estimate-human-readable="humanTimeEstimate"
/>
<time-tracking-spent-only-pane
v-if="showSpentOnlyState"
:time-spent-human-readable="timeSpentHumanReadable"
:time-spent-human-readable="humanTimeSpent"
/>
<time-tracking-no-tracking-pane
v-if="showNoTimeTrackingState"
......@@ -158,8 +144,8 @@ export default {
v-if="showComparisonState"
:time-estimate="timeEstimate"
:time-spent="timeSpent"
:time-spent-human-readable="timeSpentHumanReadable"
:time-estimate-human-readable="timeEstimateHumanReadable"
:time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
<transition name="help-state-toggle">
<time-tracking-help-state
......
......@@ -7,6 +7,8 @@ export default class SidebarMilestone {
if (!el) return;
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
......@@ -15,10 +17,10 @@ export default class SidebarMilestone {
},
render: createElement => createElement('timeTracker', {
props: {
time_estimate: parseInt(el.dataset.timeEstimate, 10),
time_spent: parseInt(el.dataset.timeSpent, 10),
human_time_estimate: el.dataset.humanTimeEstimate,
human_time_spent: el.dataset.humanTimeSpent,
timeEstimate: parseInt(timeEstimate, 10),
timeSpent: parseInt(timeSpent, 10),
humanTimeEstimate,
humanTimeSpent,
rootPath: '/',
},
}),
......
......@@ -223,6 +223,7 @@
}
}
.clipboard-group,
.commit-sha-group {
display: inline-flex;
......
......@@ -27,13 +27,17 @@
# updated_before: datetime
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
end
def klass
MergeRequest
end
def filter_items(_items)
items = by_source_branch(super)
items = by_wip(items)
by_target_branch(items)
end
......@@ -61,5 +65,20 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
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
......@@ -265,7 +265,7 @@ class MergeRequest < ActiveRecord::Base
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)
!!(title =~ WIP_REGEX)
......
- page_title @application.name, "Applications"
%h3.page-title
Application: #{@application.name}
......@@ -6,23 +7,29 @@
%table.table
%tr
%td
Application Id
= _('Application ID')
%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
%td
Secret:
= _('Secret')
%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
%td
Callback url
= _('Callback URL')
%td
- @application.redirect_uri.split.each do |uri|
%div
%span.monospace= uri
%tr
%td
Trusted
......
......@@ -10,18 +10,25 @@
%table.table
%tr
%td
= _('Application Id')
= _('Application ID')
%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
%td
= _('Secret:')
= _('Secret')
%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
%td
= _('Callback url')
= _('Callback URL')
%td
- @application.redirect_uri.split.each do |uri|
%div
......
......@@ -33,13 +33,13 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
= sprite_icon('search')
%span
Press Enter or click to search
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
......@@ -60,7 +60,7 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Assignee
%li.divider.droplab-item-ignore
- if current_user
......@@ -73,38 +73,46 @@
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Milestone
%li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
Upcoming
%li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
Started
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Label
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
%gl-emoji
%span.js-data-value.prepend-left-10
{{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
......
---
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:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `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
[
......
......@@ -7,7 +7,7 @@ have been marked a **Work In Progress**.
![Blocked Accept Button](img/wip_blocked_accept_button.png)
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.
![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
To allow a Work In Progress merge request to be accepted again when it's ready,
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
# rubocop: disable CodeReuse/ActiveRecord
def find_merge_requests(args = {})
args = declared_params.merge(args)
args[:milestone_title] = args.delete(:milestone)
args[:label_name] = args.delete(:labels)
args[:scope] = args[:scope].underscore if args[:scope]
......@@ -99,6 +98,7 @@ module API
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 :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
end
end
......
......@@ -703,7 +703,7 @@ msgstr ""
msgid "Application"
msgstr ""
msgid "Application Id"
msgid "Application ID"
msgstr ""
msgid "Application: %{name}"
......@@ -1320,9 +1320,6 @@ msgstr ""
msgid "Callback URL"
msgstr ""
msgid "Callback url"
msgstr ""
msgid "Can't find HEAD commit for this branch"
msgstr ""
......@@ -2233,6 +2230,9 @@ msgstr ""
msgid "Copy HTTPS clone URL"
msgstr ""
msgid "Copy ID to clipboard"
msgstr ""
msgid "Copy SSH clone URL"
msgstr ""
......@@ -2260,6 +2260,9 @@ msgstr ""
msgid "Copy reference to clipboard"
msgstr ""
msgid "Copy secret to clipboard"
msgstr ""
msgid "Copy to clipboard"
msgstr ""
......@@ -6694,7 +6697,7 @@ msgstr ""
msgid "Seconds to wait for a storage access attempt"
msgstr ""
msgid "Secret:"
msgid "Secret"
msgstr ""
msgid "Security"
......
......@@ -16,7 +16,7 @@ RSpec.describe 'admin manage applications' do
check :doorkeeper_application_trusted
click_on 'Submit'
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('Trusted Y')
......@@ -28,7 +28,7 @@ RSpec.describe 'admin manage applications' do
click_on 'Submit'
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('Trusted N')
......
......@@ -15,6 +15,7 @@ describe 'Dropdown hint', :js do
before do
project.add_maintainer(user)
create(:issue, project: project)
create(:merge_request, source_project: project, target_project: project)
end
context 'when user not logged in' do
......@@ -224,4 +225,21 @@ describe 'Dropdown hint', :js do
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
......@@ -16,7 +16,7 @@ describe 'User manages applications' do
click_on 'Save application'
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'
click_on 'Edit'
......@@ -26,7 +26,7 @@ describe 'User manages applications' do
click_on 'Save application'
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'
visit applications_profile_path
......
......@@ -3,25 +3,47 @@ require 'spec_helper'
describe MergeRequestsFinder do
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(:user2) { create :user }
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let(:project1) { create(:project, :public, group: group) }
let(:project2) { fork_project(project1, user) }
let(:project1) { create_project_without_n_plus_1(group: group) }
let(:project2) do
Gitlab::GitalyClient.allow_n_plus_1_calls do
fork_project(project1, user)
end
end
let(:project3) do
p = fork_project(project1, user)
p.update!(archived: true)
p
Gitlab::GitalyClient.allow_n_plus_1_calls do
p = fork_project(project1, user)
p.update!(archived: true)
p
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_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_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) }
let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) }
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, title: 'WIP thing') }
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
project1.add_maintainer(user)
......@@ -29,25 +51,27 @@ describe MergeRequestsFinder do
project3.add_developer(user)
project2.add_developer(user2)
project4.add_developer(user)
project5.add_developer(user)
project6.add_developer(user)
end
describe "#execute" do
it 'filters by scope' do
params = { scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3)
expect(merge_requests.size).to eq(7)
end
it 'filters by project' do
params = { project_id: project1.id, scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1)
expect(merge_requests.size).to eq(2)
end
it 'ignores sorting by weight' do
params = { project_id: project1.id, scope: 'authored', state: 'opened', weight: Issue::WEIGHT_ANY }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1)
expect(merge_requests.size).to eq(2)
end
it 'filters by group' do
......@@ -55,7 +79,7 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(2)
expect(merge_requests.size).to eq(3)
end
it 'filters by group including subgroups', :nested_groups do
......@@ -63,13 +87,13 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3)
expect(merge_requests.size).to eq(6)
end
it 'filters by non_archived' do
params = { non_archived: true }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(4)
expect(merge_requests.size).to eq(8)
end
it 'filters by iid' do
......@@ -104,6 +128,36 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request3)
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
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
......@@ -213,7 +267,7 @@ describe MergeRequestsFinder do
it 'returns the number of rows for the default state' do
finder = described_class.new(user)
expect(finder.row_count).to eq(3)
expect(finder.row_count).to eq(7)
end
it 'returns the number of rows for a given state' do
......
......@@ -288,13 +288,13 @@ describe('Dropdown Utils', () => {
describe('setDataValueIfSelected', () => {
beforeEach(() => {
spyOn(FilteredSearchDropdownManager, 'addWordToInput')
.and.callFake(() => {});
spyOn(FilteredSearchDropdownManager, 'addWordToInput').and.callFake(() => {});
});
it('calls addWordToInput when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
hasAttribute: () => false,
};
DropdownUtils.setDataValueIfSelected(null, selected);
......@@ -304,6 +304,7 @@ describe('Dropdown Utils', () => {
it('returns true when dataValue exists', () => {
const selected = {
getAttribute: () => 'value',
hasAttribute: () => false,
};
const result = DropdownUtils.setDataValueIfSelected(null, selected);
......
......@@ -240,13 +240,17 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => {
setFixtures(`
<div class="test-area">
${subject.createVisualTokenElementHTML()}
${subject.createVisualTokenElementHTML('custom-token')}
</div>
`);
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', () => {
expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything());
});
......@@ -280,7 +284,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => {
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');
expect(token.classList.contains('filtered-search-term')).toEqual(true);
......
......@@ -8,7 +8,10 @@ describe('Issuable Time Tracker', () => {
let initialData;
let vm;
const initTimeTrackingComponent = opts => {
const initTimeTrackingComponent = ({ timeEstimate,
timeSpent,
timeEstimateHumanReadable,
timeSpentHumanReadable }) => {
setFixtures(`
<div>
<div id="mock-container"></div>
......@@ -16,10 +19,10 @@ describe('Issuable Time Tracker', () => {
`);
initialData = {
time_estimate: opts.timeEstimate,
time_spent: opts.timeSpent,
human_time_estimate: opts.timeEstimateHumanReadable,
human_time_spent: opts.timeSpentHumanReadable,
timeEstimate,
timeSpent,
humanTimeEstimate: timeEstimateHumanReadable,
humanTimeSpent: timeSpentHumanReadable,
rootPath: '/',
};
......@@ -43,8 +46,8 @@ describe('Issuable Time Tracker', () => {
describe('Initialization', () => {
beforeEach(() => {
initTimeTrackingComponent({
timeEstimate: 100000,
timeSpent: 5000,
timeEstimate: 10000, // 2h 46m
timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '1h 23m',
});
......@@ -56,14 +59,14 @@ describe('Issuable Time Tracker', () => {
it('should correctly set timeEstimate', done => {
Vue.nextTick(() => {
expect(vm.timeEstimate).toBe(initialData.time_estimate);
expect(vm.timeEstimate).toBe(initialData.timeEstimate);
done();
});
});
it('should correctly set time_spent', done => {
Vue.nextTick(() => {
expect(vm.timeSpent).toBe(initialData.time_spent);
expect(vm.timeSpent).toBe(initialData.timeSpent);
done();
});
});
......@@ -74,8 +77,8 @@ describe('Issuable Time Tracker', () => {
describe('Comparison pane', () => {
beforeEach(() => {
initTimeTrackingComponent({
timeEstimate: 100000,
timeSpent: 5000,
timeEstimate: 100000, // 1d 3h
timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '',
timeSpentHumanReadable: '',
});
......@@ -106,8 +109,8 @@ describe('Issuable Time Tracker', () => {
});
it('should display the remaining meter with the correct background color when over estimate', done => {
vm.time_estimate = 100000;
vm.time_spent = 20000000;
vm.timeEstimate = 10000; // 2h 46m
vm.timeSpent = 20000000; // 231 days
Vue.nextTick(() => {
expect(vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]')).not.toBeNull();
done();
......@@ -119,7 +122,7 @@ describe('Issuable Time Tracker', () => {
describe('Estimate only pane', () => {
beforeEach(() => {
initTimeTrackingComponent({
timeEstimate: 100000,
timeEstimate: 10000, // 2h 46m
timeSpent: 0,
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '',
......@@ -142,7 +145,7 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => {
initTimeTrackingComponent({
timeEstimate: 0,
timeSpent: 5000,
timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '1h 23m',
});
......
......@@ -747,7 +747,7 @@ describe MergeRequest do
end
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
wipless_title = subject.title
subject.title = "#{wip_prefix}#{subject.title}"
......
......@@ -81,6 +81,35 @@ describe API::MergeRequests do
let(:user2) { create(:user) }
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)
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
expect(response).to have_gitlab_http_status(404)
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
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