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

Merge remote-tracking branch 'origin/master' into rc/ce-to-ee-wednesday

Signed-off-by: default avatarRémy Coutable <remy@rymai.me>
parents 3793c094 ec4788d0
/* global Vue */
const userFilter = require('./filters/user');
const milestoneFilter = require('./filters/milestone');
const labelFilter = require('./filters/label');
import FilteredSearchBoards from '../../filtered_search_boards';
import FilteredSearchContainer from '../../../filtered_search/container';
module.exports = Vue.extend({
export default {
name: 'modal-filters',
props: {
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
store: {
type: Object,
required: true,
},
},
destroyed() {
gl.issueBoards.ModalStore.setDefaultFilter();
mounted() {
FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store);
this.filteredSearch.removeTokens();
},
components: {
userFilter,
milestoneFilter,
labelFilter,
beforeDestroy() {
this.filteredSearch.cleanup();
FilteredSearchContainer.container = document;
this.store.path = '';
},
template: `
<div class="modal-filters">
<user-filter
dropdown-class-name="dropdown-menu-author"
toggle-class-name="js-user-search js-author-search"
toggle-label="Author"
field-name="author_id"
:project-id="projectId"></user-filter>
<user-filter
dropdown-class-name="dropdown-menu-author"
toggle-class-name="js-assignee-search"
toggle-label="Assignee"
field-name="assignee_id"
:null-user="true"
:project-id="projectId"></user-filter>
<milestone-filter :milestone-path="milestonePath"></milestone-filter>
<label-filter :label-path="labelPath"></label-filter>
</div>
`,
});
template: '#js-board-modal-filter',
};
/* eslint-disable no-new */
/* global Vue */
/* global LabelsSelect */
module.exports = Vue.extend({
name: 'filter-label',
props: {
labelPath: {
type: String,
required: true,
},
},
mounted() {
new LabelsSelect(this.$refs.dropdown);
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-label-select js-multiselect js-extra-options"
type="button"
data-toggle="dropdown"
data-show-any="true"
data-show-no="true"
:data-labels="labelPath"
ref="dropdown">
<span class="dropdown-toggle-text">
Label
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable">
<div class="dropdown-title">
Filter by label
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Search"
autocomplete="off" />
<i class="fa fa-search dropdown-input-search"></i>
<i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* eslint-disable no-new */
/* global Vue */
/* global MilestoneSelect */
module.exports = Vue.extend({
name: 'filter-milestone',
props: {
milestonePath: {
type: String,
required: true,
},
},
mounted() {
new MilestoneSelect(null, this.$refs.dropdown);
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-milestone-select"
type="button"
data-toggle="dropdown"
data-show-any="true"
data-show-upcoming="true"
data-field-name="milestone_title"
:data-milestones="milestonePath"
ref="dropdown">
<span class="dropdown-toggle-text">
Milestone
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-milestone">
<div class="dropdown-title">
<span>Filter by milestone</span>
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
placeholder="Search milestones"
autocomplete="off" />
<i class="fa fa-search dropdown-input-search"></i>
<i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* eslint-disable no-new */
/* global Vue */
/* global UsersSelect */
module.exports = Vue.extend({
name: 'filter-user',
props: {
toggleClassName: {
type: String,
required: true,
},
dropdownClassName: {
type: String,
required: false,
default: '',
},
toggleLabel: {
type: String,
required: true,
},
fieldName: {
type: String,
required: true,
},
nullUser: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: true,
},
},
mounted() {
new UsersSelect(null, this.$refs.dropdown);
},
computed: {
currentUsername() {
return gon.current_username;
},
dropdownTitle() {
return `Filter by ${this.toggleLabel.toLowerCase()}`;
},
inputPlaceholder() {
return `Search ${this.toggleLabel.toLowerCase()}`;
},
},
template: `
<div class="dropdown">
<button
class="dropdown-menu-toggle js-user-search"
:class="toggleClassName"
type="button"
data-toggle="dropdown"
data-current-user="true"
:data-any-user="'Any ' + toggleLabel"
:data-null-user="nullUser"
:data-field-name="fieldName"
:data-project-id="projectId"
:data-first-user="currentUsername"
ref="dropdown">
<span class="dropdown-toggle-text">
{{ toggleLabel }}
</span>
<i class="fa fa-chevron-down"></i>
</button>
<div
class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable"
:class="dropdownClassName">
<div class="dropdown-title">
{{ dropdownTitle }}
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button">
<i class="fa fa-times dropdown-menu-close-icon"></i>
</button>
</div>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
autocomplete="off"
:placeholder="inputPlaceholder" />
<i class="fa fa-search dropdown-input-search"></i>
<i
role="button"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div>
</div>
</div>
`,
});
/* global Vue */
import Vue from 'vue';
import modalFilters from './filters';
require('./tabs');
const modalFilters = require('./filters');
(() => {
const ModalStore = gl.issueBoards.ModalStore;
......@@ -66,16 +67,7 @@ const modalFilters = require('./filters');
<div
class="add-issues-search append-bottom-10"
v-if="showSearch">
<modal-filters
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath">
</modal-filters>
<input
placeholder="Search issues..."
class="form-control"
type="search"
v-model="searchTerm" />
<modal-filters :store="filter" />
<button
type="button"
class="btn btn-success btn-inverted prepend-left-10"
......
/* global Vue */
/* global ListIssue */
import queryData from '../../utils/query_data';
require('./header');
require('./list');
......@@ -47,9 +48,6 @@ require('./empty_state');
page() {
this.loadIssues();
},
searchTerm() {
this.searchOperation();
},
showAddIssuesModal() {
if (this.showAddIssuesModal && !this.issues.length) {
this.loading = true;
......@@ -72,19 +70,13 @@ require('./empty_state');
},
},
methods: {
searchOperation: _.debounce(function searchOperationDebounce() {
this.loadIssues(true);
}, 500),
loadIssues(clearIssues = false) {
if (!this.showAddIssuesModal) return false;
const queryData = Object.assign({}, this.filter, {
search: this.searchTerm,
return gl.boardService.getBacklog(queryData(this.filter.path, {
page: this.page,
per: this.perPage,
});
return gl.boardService.getBacklog(queryData).then((res) => {
})).then((res) => {
const data = res.json();
if (clearIssues) {
......
/* eslint-disable class-methods-use-this */
import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
super('boards');
......@@ -19,13 +22,17 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
}
}
updateTokens() {
const tokens = document.querySelectorAll('.js-visual-token');
removeTokens() {
const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token');
// Remove all the tokens as they will be replaced by the search manager
[].forEach.call(tokens, (el) => {
el.parentNode.removeChild(el);
});
}
updateTokens() {
this.removeTokens();
this.loadSearchParamsFromURL();
......
......@@ -7,4 +7,8 @@ module.exports = [
id: -2,
title: 'Upcoming',
},
{
id: -3,
title: 'Started',
},
];
/* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */
/* global ListIssue */
/* global ListLabel */
import queryData from '../utils/query_data';
class List {
constructor (obj) {
......@@ -64,25 +65,7 @@ class List {
}
getIssues (emptyIssues = true) {
const data = gl.issueBoards.BoardsStore.filter.path.split('&').reduce((data, filterParam) => {
if (filterParam === '') return data;
const paramSplit = filterParam.split('=');
const paramKeyNormalized = paramSplit[0].replace('[]', '');
const isArray = paramSplit[0].indexOf('[]');
const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' ');
if (isArray !== -1) {
if (!data[paramKeyNormalized]) {
data[paramKeyNormalized] = [];
}
data[paramKeyNormalized].push(value);
} else {
data[paramKeyNormalized] = value;
}
return data;
}, { page: this.page });
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
if (this.label && data.label_name) {
data.label_name = data.label_name.filter(label => label !== this.label.title);
......
......@@ -17,17 +17,9 @@
loadingNewPage: false,
page: 1,
perPage: 50,
};
this.setDefaultFilter();
}
setDefaultFilter() {
this.store.filter = {
author_id: '',
assignee_id: '',
milestone_title: '',
label_name: [],
filter: {
path: '',
},
};
}
......
export default (path, extraData) => path.split('&').reduce((dataParam, filterParam) => {
if (filterParam === '') return dataParam;
const data = dataParam;
const paramSplit = filterParam.split('=');
const paramKeyNormalized = paramSplit[0].replace('[]', '');
const isArray = paramSplit[0].indexOf('[]');
const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' ');
if (isArray !== -1) {
if (!data[paramKeyNormalized]) {
data[paramKeyNormalized] = [];
}
data[paramKeyNormalized].push(value);
} else {
data[paramKeyNormalized] = value;
}
return data;
}, extraData);
......@@ -15,6 +15,7 @@ export default {
service: {
type: Object,
required: true,
default: () => ({}),
},
},
......
/**
* Environment Item Component
*
* Renders a table row for each environment.
*/
import Timeago from 'timeago.js';
import ActionsComponent from './environment_actions';
import ExternalUrlComponent from './environment_external_url';
......@@ -47,6 +53,7 @@ export default {
service: {
type: Object,
required: true,
default: () => ({}),
},
},
......
......@@ -23,6 +23,7 @@ export default {
service: {
type: Object,
required: true,
default: () => ({}),
},
},
......
......@@ -16,6 +16,7 @@ export default {
service: {
type: Object,
required: true,
default: () => ({}),
},
},
......
......@@ -4,7 +4,6 @@
* Dumb component used to render top level environments and
* the folder view.
*/
import EnvironmentItem from './environment_item';
import DeployBoard from './deploy_board_component';
......@@ -48,6 +47,7 @@ export default {
service: {
type: Object,
required: true,
default: () => ({}),
},
},
......
......@@ -4,6 +4,7 @@ import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table';
import EnvironmentsStore from '../stores/environments_store';
const Flash = require('~/flash');
const Vue = window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
require('../../vue_shared/components/table_pagination');
......@@ -183,8 +184,7 @@ export default Vue.component('environment-folder-view', {
:commit-icon-svg="commitIconSvg"
:toggleDeployBoard="toggleDeployBoard"
:store="store"
:service="service">
</environment-table>
:service="service"/>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
......
/* eslint-disable class-methods-use-this */
let container = document;
class FilteredSearchContainerClass {
set container(containerParam) {
container = containerParam;
}
get container() {
return container;
}
}
export default new FilteredSearchContainerClass();
......@@ -45,7 +45,7 @@ require('./filtered_search_dropdown');
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
}
gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''));
gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container);
}
this.dismissDropdown();
this.dispatchInputEvent();
......@@ -57,13 +57,15 @@ require('./filtered_search_dropdown');
const dropdownData = [];
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag } = dropdownMenu.dataset;
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push({
icon: `fa-${icon}`,
hint,
tag: `&lt;${tag}&gt;`,
});
dropdownData.push(
Object.assign({
icon: `fa-${icon}`,
hint,
tag: `&lt;${tag}&gt;`,
}, type && { type }),
);
}
});
......
import FilteredSearchContainer from './container';
(() => {
class DropdownUtils {
static getEscapedText(text) {
......@@ -51,14 +53,18 @@
static filterHint(input, item) {
const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchInput(input);
let { lastToken } = gl.FilteredSearchTokenizer.processTokens(searchInput);
lastToken = lastToken.key || lastToken || '';
if (!lastToken || searchInput.split('').last() === ' ') {
const searchInput = gl.DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput);
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || searchInput.split('').last() === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastToken) {
const split = lastToken.split(':');
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = split[0].split(' ').last();
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
......@@ -81,7 +87,8 @@
// Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) {
const tokens = [].slice.call(document.querySelectorAll('.tokens-container li'));
const container = FilteredSearchContainer.container;
const tokens = [].slice.call(container.querySelectorAll('.tokens-container li'));
const values = [];
if (untilInput) {
......@@ -110,7 +117,7 @@
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const input = document.querySelector('.filtered-search');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputValue = input && input.value;
if (isLastVisualTokenValid) {
......
/* global DropLab */
import FilteredSearchContainer from './container';
(() => {
class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', page) {
this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = gl.FilteredSearchTokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = document.querySelector('.filtered-search');
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
if (this.page === 'issues' || this.page === 'boards') {
......@@ -35,29 +37,29 @@
author: {
reference: null,
gl: 'DropdownUser',
element: document.querySelector('#js-dropdown-author'),
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: 'DropdownUser',
element: document.querySelector('#js-dropdown-assignee'),
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'],
element: document.querySelector('#js-dropdown-milestone'),
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: 'DropdownNonUser',
extraArguments: [`${this.baseEndpoint}/labels.json`, '~'],
element: document.querySelector('#js-dropdown-label'),
element: this.container.querySelector('#js-dropdown-label'),
},
hint: {
reference: null,
gl: 'DropdownHint',
element: document.querySelector('#js-dropdown-hint'),
element: this.container.querySelector('#js-dropdown-hint'),
},
};
......@@ -71,7 +73,7 @@
}
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = document.querySelector('.filtered-search');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
......@@ -87,13 +89,13 @@
updateDropdownOffset(key) {
// Always align dropdown with the input field
let offset = this.filteredSearchInput.getBoundingClientRect().left - document.querySelector('.scroll-container').getBoundingClientRect().left;
let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left;
const maxInputWidth = 240;
const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
// Make sure offset never exceeds the input container
const offsetMaxWidth = document.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
if (offsetMaxWidth < offset) {
offset = offsetMaxWidth;
}
......
import FilteredSearchContainer from './container';
(() => {
class FilteredSearchManager {
constructor(page) {
this.filteredSearchInput = document.querySelector('.filtered-search');
this.clearSearchButton = document.querySelector('.clear-search');
this.tokensContainer = document.querySelector('.tokens-container');
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
if (page === 'issues' || page === 'boards') {
......@@ -138,7 +141,7 @@
}
unselectEditTokens(e) {
const inputContainer = document.querySelector('.filtered-search-input-container');
const inputContainer = this.container.querySelector('.filtered-search-input-container');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
......
......@@ -42,6 +42,10 @@
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: 'upcoming',
}, {
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: 'started',
}, {
url: 'label_name[]=No+Label',
tokenKey: 'label',
......
import FilteredSearchContainer from './container';
class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
const inputLi = document.querySelector('.input-token');
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
return {
......@@ -10,7 +12,7 @@ class FilteredSearchVisualTokens {
}
static unselectTokens() {
const otherTokens = document.querySelectorAll('.js-visual-token .selectable.selected');
const otherTokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token .selectable.selected');
[].forEach.call(otherTokens, t => t.classList.remove('selected'));
}
......@@ -24,7 +26,7 @@ class FilteredSearchVisualTokens {
}
static removeSelectedToken() {
const selected = document.querySelector('.js-visual-token .selected');
const selected = FilteredSearchContainer.container.querySelector('.js-visual-token .selected');
if (selected) {
const li = selected.closest('.js-visual-token');
......@@ -54,8 +56,8 @@ class FilteredSearchVisualTokens {
}
li.querySelector('.name').innerText = name;
const tokensContainer = document.querySelector('.tokens-container');
const input = document.querySelector('.filtered-search');
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
tokensContainer.insertBefore(li, input.parentElement);
}
......@@ -77,14 +79,14 @@ class FilteredSearchVisualTokens {
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue);
addVisualTokenElement(tokenName, tokenValue, false);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = document.querySelector('.tokens-container');
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value);
addVisualTokenElement(previousTokenName, value, false);
}
}
......@@ -129,7 +131,7 @@ class FilteredSearchVisualTokens {
}
static tokenizeInput() {
const input = document.querySelector('.filtered-search');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
......@@ -145,7 +147,7 @@ class FilteredSearchVisualTokens {
}
static editToken(token) {
const input = document.querySelector('.filtered-search');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
FilteredSearchVisualTokens.tokenizeInput();
......@@ -174,9 +176,9 @@ class FilteredSearchVisualTokens {
}
static moveInputToTheRight() {
const input = document.querySelector('.filtered-search');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
const inputLi = input.parentElement;
const tokenContainer = document.querySelector('.tokens-container');
const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
FilteredSearchVisualTokens.tokenizeInput();
......
......@@ -6,14 +6,32 @@ require('../../../lib/utils/pretty_time');
(() => {
Vue.component('time-tracking-collapsed-state', {
name: 'time-tracking-collapsed-state',
props: [
'showComparisonState',
'showSpentOnlyState',
'showEstimateOnlyState',
'showNoTimeTrackingState',
'timeSpentHumanReadable',
'timeEstimateHumanReadable',
],
props: {
showComparisonState: {
type: Boolean,
required: true,
},
showSpentOnlyState: {
type: Boolean,
required: true,
},
showEstimateOnlyState: {
type: Boolean,
required: true,
},
showNoTimeTrackingState: {
type: Boolean,
required: true,
},
timeSpentHumanReadable: {
type: String,
required: false,
},
timeEstimateHumanReadable: {
type: String,
required: false,
},
},
methods: {
abbreviateTime(timeStr) {
return gl.utils.prettyTime.abbreviateTime(timeStr);
......
......@@ -6,12 +6,24 @@ require('../../../lib/utils/pretty_time');
Vue.component('time-tracking-comparison-pane', {
name: 'time-tracking-comparison-pane',
props: [
'timeSpent',
'timeEstimate',
'timeSpentHumanReadable',
'timeEstimateHumanReadable',
],
props: {
timeSpent: {
type: Number,
required: true,
},
timeEstimate: {
type: Number,
required: true,
},
timeSpentHumanReadable: {
type: String,
required: true,
},
timeEstimateHumanReadable: {
type: String,
required: true,
},
},
computed: {
parsedRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
......
......@@ -2,7 +2,12 @@
(() => {
Vue.component('time-tracking-estimate-only-pane', {
name: 'time-tracking-estimate-only-pane',
props: ['timeEstimateHumanReadable'],
props: {
timeEstimateHumanReadable: {
type: String,
required: true,
},
},
template: `
<div class='time-tracking-estimate-only-pane'>
<span class='bold'>Estimated:</span>
......
......@@ -2,7 +2,12 @@
(() => {
Vue.component('time-tracking-help-state', {
name: 'time-tracking-help-state',
props: ['docsUrl'],
props: {
docsUrl: {
type: String,
required: true,
},
},
template: `
<div class='time-tracking-help-state'>
<div class='time-tracking-info'>
......
......@@ -2,7 +2,12 @@
(() => {
Vue.component('time-tracking-spent-only-pane', {
name: 'time-tracking-spent-only-pane',
props: ['timeSpentHumanReadable'],
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
template: `
<div class='time-tracking-spend-only-pane'>
<span class='bold'>Spent:</span>
......
......@@ -10,13 +10,30 @@ require('./comparison_pane');
(() => {
Vue.component('issuable-time-tracker', {
name: 'issuable-time-tracker',
props: [
'time_estimate',
'time_spent',
'human_time_estimate',
'human_time_spent',
'docsUrl',
],
props: {
time_estimate: {
type: Number,
required: true,
default: 0,
},
time_spent: {
type: Number,
required: true,
default: 0,
},
human_time_estimate: {
type: String,
required: false,
},
human_time_spent: {
type: String,
required: false,
},
docsUrl: {
type: String,
required: true,
},
},
data() {
return {
showHelp: false,
......@@ -66,6 +83,7 @@ require('./comparison_pane');
<div class='time_tracker time-tracking-component-wrap' v-cloak>
<time-tracking-collapsed-state
:show-comparison-state='showComparisonState'
:show-no-time-tracking-state='showNoTimeTrackingState'
:show-help-state='showHelpState'
:show-spent-only-state='showSpentOnlyState'
:show-estimate-only-state='showEstimateOnlyState'
......
......@@ -19,7 +19,7 @@
}
$els.each(function(i, dropdown) {
var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId, showMenuAbove;
var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
milestonesUrl = $dropdown.data('milestones');
......@@ -29,6 +29,7 @@
showAny = $dropdown.data('show-any');
showMenuAbove = $dropdown.data('showMenuAbove');
showUpcoming = $dropdown.data('show-upcoming');
showStarted = $dropdown.data('show-started');
useId = $dropdown.data('use-id');
defaultLabel = $dropdown.data('default-label');
issuableId = $dropdown.data('issuable-id');
......@@ -71,6 +72,13 @@
title: 'Upcoming'
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
title: 'Started'
});
}
if (extraOptions.length) {
extraOptions.push('divider');
}
......
......@@ -449,12 +449,9 @@
display: -webkit-flex;
display: flex;
.form-control {
margin-left: auto;
@media (min-width: $screen-sm-min) {
max-width: 200px;
}
.issues-filters {
-webkit-flex: 1;
flex: 1;
}
}
......
......@@ -42,7 +42,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
private
def load_projects(base_scope)
projects = base_scope.sorted_by_activity.includes(:namespace)
projects = base_scope.sorted_by_activity.includes(:route, namespace: :route)
filter_projects(projects)
end
......
......@@ -2,7 +2,7 @@ class Explore::ProjectsController < Explore::ApplicationController
include FilterProjects
def index
@projects = ProjectsFinder.new.execute(current_user)
@projects = load_projects
@tags = @projects.tags_on(:tags)
@projects = @projects.tagged_with(params[:tag]) if params[:tag].present?
@projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present?
......@@ -21,7 +21,8 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def trending
@projects = filter_projects(Project.trending)
@projects = load_projects(Project.trending)
@projects = filter_projects(@projects)
@projects = @projects.sort(@sort = params[:sort])
@projects = @projects.page(params[:page])
......@@ -36,7 +37,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def starred
@projects = ProjectsFinder.new.execute(current_user)
@projects = load_projects
@projects = filter_projects(@projects)
@projects = @projects.reorder('star_count DESC')
@projects = @projects.page(params[:page])
......@@ -50,4 +51,11 @@ class Explore::ProjectsController < Explore::ApplicationController
end
end
end
protected
def load_projects(base_scope = nil)
base_scope ||= ProjectsFinder.new.execute(current_user)
base_scope.includes(:route, namespace: :route)
end
end
......@@ -312,6 +312,10 @@ class IssuableFinder
params[:milestone_title] == Milestone::Upcoming.name
end
def filter_by_started_milestone?
params[:milestone_title] == Milestone::Started.name
end
def by_milestone(items)
if milestones?
if filter_by_no_milestone?
......@@ -319,6 +323,8 @@ class IssuableFinder
elsif filter_by_upcoming_milestone?
upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items))
items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
elsif filter_by_started_milestone?
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else
items = items.with_milestone(params[:milestone_title])
items_projects = projects(items)
......
......@@ -59,6 +59,24 @@ module CiStatusHelper
custom_icon(icon_name)
end
def pipeline_status_cache_key(pipeline_status)
"pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
end
def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left')
project = pipeline_status.project
path = pipelines_namespace_project_commit_path(
project.namespace,
project,
pipeline_status.sha)
render_status_with_link(
'commit',
pipeline_status.status,
path,
tooltip_placement: tooltip_placement)
end
def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left')
project = commit.project
path = pipelines_namespace_project_commit_path(
......
......@@ -90,11 +90,14 @@ module IssuablesHelper
end
def milestone_dropdown_label(milestone_title, default_label = "Milestone")
if milestone_title == Milestone::Upcoming.name
milestone_title = Milestone::Upcoming.title
end
title =
case milestone_title
when Milestone::Upcoming.name then Milestone::Upcoming.title
when Milestone::Started.name then Milestone::Started.title
else milestone_title.presence
end
h(milestone_title.presence || default_label)
h(title || default_label)
end
def to_url_reference(issuable)
......
......@@ -159,6 +159,13 @@ module ProjectsHelper
choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe
end
def project_list_cache_key(project)
key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.3']
key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
key
end
private
def repo_children_classes(field)
......
......@@ -18,7 +18,8 @@ module SortingHelper
sort_value_upvotes => sort_title_upvotes,
sort_value_more_weight => sort_title_more_weight,
sort_value_less_weight => sort_title_less_weight,
sort_value_priority => sort_title_priority
sort_value_priority => sort_title_priority,
sort_value_label_priority => sort_title_label_priority
}
end
......@@ -52,6 +53,10 @@ module SortingHelper
end
def sort_title_priority
'Priority'
end
def sort_title_label_priority
'Label priority'
end
......@@ -171,6 +176,10 @@ module SortingHelper
'priority'
end
def sort_value_label_priority
'label_priority'
end
def sort_value_oldest_updated
'updated_asc'
end
......
......@@ -22,6 +22,7 @@ module Ci
validate :valid_commit_sha, unless: :importing?
after_create :keep_around_commits, unless: :importing?
after_create :refresh_build_status_cache
state_machine :status, initial: :created do
event :enqueue do
......@@ -328,6 +329,7 @@ module Ci
when 'manual' then block
end
end
refresh_build_status_cache
end
def predefined_variables
......@@ -369,6 +371,10 @@ module Ci
.fabricate!
end
def refresh_build_status_cache
Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed
end
private
def pipeline_data
......
# This class is not backed by a table in the main database.
# It loads the latest Pipeline for the HEAD of a repository, and caches that
# in Redis.
module Ci
class PipelineStatus
attr_accessor :sha, :status, :project, :loaded
delegate :commit, to: :project
def self.load_for_project(project)
new(project).tap do |status|
status.load_status
end
end
def initialize(project, sha: nil, status: nil)
@project = project
@sha = sha
@status = status
end
def has_status?
loaded? && sha.present? && status.present?
end
def load_status
return if loaded?
if has_cache?
load_from_cache
else
load_from_commit
store_in_cache
end
self.loaded = true
end
def load_from_commit
return unless commit
self.sha = commit.sha
self.status = commit.status
end
# We only cache the status for the HEAD commit of a project
# This status is rendered in project lists
def store_in_cache_if_needed
return unless sha
return delete_from_cache unless commit
store_in_cache if commit.sha == self.sha
end
def load_from_cache
Gitlab::Redis.with do |redis|
self.sha, self.status = redis.hmget(cache_key, :sha, :status)
end
end
def store_in_cache
Gitlab::Redis.with do |redis|
redis.mapped_hmset(cache_key, { sha: sha, status: status })
end
end
def delete_from_cache
Gitlab::Redis.with do |redis|
redis.del(cache_key)
end
end
def has_cache?
Gitlab::Redis.with do |redis|
redis.exists(cache_key)
end
end
def loaded?
self.loaded
end
def cache_key
"projects/#{project.id}/build_status"
end
end
end
......@@ -31,7 +31,7 @@ module Elastic
commits = response.map do |result|
commit result["_source"]["commit"]["sha"]
end
end.compact
# Before "map" we had a paginated array so we need to recover it
offset = per_page * ((page || 1) - 1)
......@@ -49,11 +49,16 @@ module Elastic
options: options
)[:commits][:results]
# Avoid one SELECT per result by loading all projects into a hash
project_ids = response.map {|result| result["_source"]["commit"]["rid"] }.uniq
projects = Project.where(id: project_ids).index_by(&:id)
commits = response.map do |result|
sha = result["_source"]["commit"]["sha"]
project = Project.find(result["_source"]["commit"]["rid"])
project.commit(sha)
end
project_id = result["_source"]["commit"]["rid"]
projects[project_id].try(:commit, sha)
end.compact
# Before "map" we had a paginated array so we need to recover it
offset = per_page * ((page || 1) - 1)
......
......@@ -147,7 +147,8 @@ module Issuable
when 'milestone_due_desc' then order_milestone_due_desc
when 'downvotes_desc' then order_downvotes_desc
when 'upvotes_desc' then order_upvotes_desc
when 'priority' then order_labels_priority(excluded_labels: excluded_labels)
when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels)
when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
when 'position_asc' then order_position_asc
else
order_by(method)
......@@ -157,7 +158,28 @@ module Issuable
sorted.order(id: :desc)
end
def order_labels_priority(excluded_labels: [])
def order_due_date_and_labels_priority(excluded_labels: [])
# The order_ methods also modify the query in other ways:
#
# - For milestones, we add a JOIN.
# - For label priority, we change the SELECT, and add a GROUP BY.#
#
# After doing those, we need to reorder to the order we want. The existing
# ORDER BYs won't work because:
#
# 1. We need milestone due date first.
# 2. We can't ORDER BY a column that isn't in the GROUP BY and doesn't
# have an aggregate function applied, so we do a useless MIN() instead.
#
milestones_due_date = 'MIN(milestones.due_date)'
order_milestone_due_asc.
order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]).
reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'),
Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
def order_labels_priority(excluded_labels: [], extra_select_columns: [])
params = {
target_type: name,
target_column: "#{table_name}.id",
......@@ -167,7 +189,12 @@ module Issuable
highest_priority = highest_label_priority(params).to_sql
select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
select_columns = [
"#{table_name}.*",
"(#{highest_priority}) AS highest_priority"
] + extra_select_columns
select(select_columns.join(', ')).
group(arel_table[:id]).
reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
......
......@@ -5,6 +5,7 @@ class Milestone < ActiveRecord::Base
None = MilestoneStruct.new('No Milestone', 'No Milestone', 0)
Any = MilestoneStruct.new('Any Milestone', '', -1)
Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
Started = MilestoneStruct.new('Started', '#started', -3)
include CacheMarkdownField
include InternalId
......
......@@ -1414,6 +1414,10 @@ class Project < ActiveRecord::Base
end
end
def pipeline_status
@pipeline_status ||= Ci::PipelineStatus.load_for_project(self)
end
def mark_import_as_failed(error_message)
original_errors = errors.dup
sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
......
......@@ -48,8 +48,14 @@ class Todo < ActiveRecord::Base
after_save :keep_around_commit
class << self
# Priority sorting isn't displayed in the dropdown, because we don't show
# milestones, but still show something if the user has a URL with that
# selected.
def sort(method)
method == "priority" ? order_by_labels_priority : order_by(method)
case method.to_s
when 'priority', 'label_priority' then order_by_labels_priority
else order_by(method)
end
end
# Order by priority depending on which issue/merge request the Todo belongs to
......
......@@ -57,8 +57,8 @@
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-sort
%li
= link_to todos_filter_path(sort: sort_value_priority) do
= sort_title_priority
= link_to todos_filter_path(sort: sort_value_label_priority) do
= sort_title_label_priority
= link_to todos_filter_path(sort: sort_value_recently_created) do
= sort_title_recently_created
= link_to todos_filter_path(sort: sort_value_oldest_created) do
......
= content_for :sub_nav do
.scrolling-tabs-container.sub-nav-scroll
= render 'shared/nav_scroll'
.nav-links.sub-nav.scrolling-tabs
%ul{ class: container_class }
= nav_link(path: 'groups#edit') do
= link_to edit_group_path(@group), title: 'General' do
%span
General
= nav_link(path: 'groups#projects') do
= link_to projects_group_path(@group), title: 'Projects' do
%span
Projects
- if ldap_enabled?
= nav_link(path: 'ldap_group_links#index') do
= link_to group_ldap_group_links_path(@group), title: 'LDAP Group' do
%span
LDAP Group
= nav_link(path: 'hooks#index') do
= link_to group_hooks_path(@group), title: 'Webhooks' do
%span
Webhooks
= nav_link(path: 'audit_events#index') do
= link_to group_audit_events_path(@group), title: 'Audit Events' do
%span
Audit Events
- if @group.shared_runners_enabled? && @group.shared_runners_minutes_limit_enabled?
= nav_link(path: 'pipeline_quota#index') do
= link_to group_audit_events_path(@group), title: 'Pipelines quota' do
%span
Pipelines quota
- page_title "Contribution Analytics"
- header_title group_title(@group, "Contribution Analytics", group_analytics_path(@group))
= render "groups/settings_head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_d3')
= page_specific_javascript_bundle_tag('graphs')
......
- page_title "Audit Events"
= render "groups/settings_head"
%h3.page-title Group Audit Events
%p.light Events in #{@group.name}
......
= render "groups/settings_head"
.panel.panel-default.prepend-top-default
.panel-heading
Group settings
......
= render "groups/settings_head"
= render 'shared/web_hooks/form', hook: @hook, hooks: @hooks, url_components: [@group]
- page_title "Pipelines quota"
= render "groups/settings_head"
%h3.page-title
Group pipelines quota
= link_to icon('question-circle'), help_page_path("user/admin_area/settings/continuous_integration", anchor: "shared-runners-build-minutes-quota"), target: '_blank'
......
- page_title "Projects"
= render "groups/settings_head"
.panel.panel-default.prepend-top-default
.panel-heading
......
= render 'layouts/nav/group_settings'
.scrolling-tabs-container{ class: nav_control_class }
.fade-left
= icon('angle-left')
......@@ -25,8 +24,12 @@
= link_to group_group_members_path(@group), title: 'Members' do
%span
Members
= nav_link(controller: [:stats]) do
= link_to group_analytics_path(@group), title: 'Contribution Analytics', data: {placement: 'right'} do
%span
Contribution Analytics
- if current_user && can?(current_user, :admin_group, @group)
= nav_link(path: %w[groups#projects groups#edit ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]) do
= link_to edit_group_path(@group), title: 'Settings' do
%span
Settings
- if current_user
- can_admin_group = can?(current_user, :admin_group, @group)
- can_edit = can?(current_user, :admin_group, @group)
- if can_admin_group || can_edit
.controls
.dropdown.group-settings-dropdown
%a.dropdown-new.btn.btn-default#group-settings-button{ href: '#', 'data-toggle' => 'dropdown' }
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
- if can_admin_group
= nav_link(path: 'groups#projects') do
= link_to 'Projects', projects_group_path(@group), title: 'Projects'
- if can_edit && can_admin_group
%li.divider
- if can_edit
- if ldap_enabled?
= nav_link(controller: :ldap_group_links) do
= link_to group_ldap_group_links_path(@group), title: "LDAP Groups" do
%span
LDAP Groups
= nav_link(controller: :hooks) do
= link_to group_hooks_path(@group), title: "Webhooks" do
%span
Webhooks
= nav_link(controller: :audit_events) do
= link_to group_audit_events_path(@group), title: "Audit Events" do
%span
Audit Events
- if @group.shared_runners_enabled? && @group.shared_runners_minutes_limit_enabled?
= nav_link(controller: :pipeline_quota) do
= link_to group_pipeline_quota_path(@group), title: "Pipelines quota" do
%span
Pipelines quota
%li
= link_to 'Edit Group', edit_group_path(@group)
......@@ -11,6 +11,7 @@
%script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board"
%script#js-board-list-template{ type: "text/x-template" }= render "projects/boards/components/board_list"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
= render "projects/issues/head"
......
......@@ -10,6 +10,8 @@
%li
= link_to page_filter_path(sort: sort_value_priority, label: true) do
= sort_title_priority
= link_to page_filter_path(sort: sort_value_label_priority, label: true) do
= sort_title_label_priority
= link_to page_filter_path(sort: sort_value_recently_created, label: true) do
= sort_title_recently_created
= link_to page_filter_path(sort: sort_value_oldest_created, label: true) do
......
......@@ -5,7 +5,7 @@
.col-xs-12.col-sm-6
.text-content
%h4 Labels can be applied to issues and merge requests to categorize them.
%p You can also star label to make it a priority label.
%p You can also star a label to make it a priority label.
- if can?(current_user, :admin_label, @project)
= link_to 'New label', new_namespace_project_label_path(@project.namespace, @project), class: 'btn btn-new', title: 'New label', id: 'new_label_link'
= link_to 'Generate a default set of labels', generate_namespace_project_labels_path(@project.namespace, @project), method: :post, class: 'btn btn-success btn-inverted', title: 'Generate a default set of labels', id: 'generate_labels_link'
......@@ -25,7 +25,7 @@
placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
.filter-item.inline.milestone-filter
= render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, board: board
= render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, board: board, show_started: true
.filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
......
......@@ -9,7 +9,7 @@
- if selected.present? || params[:milestone_title].present?
= hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id)
= dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone",
placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do
- if project
%ul.dropdown-footer-list
- if can? current_user, :admin_milestone, project
......
- type = local_assigns.fetch(:type)
- board = local_assigns.fetch(:board, nil)
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
.issues-filters
.issues-details-filters.row-content-block.second-block.filtered-search-block
.issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards && board
#js-multiple-boards-switcher.inline.boards-switcher{ "v-cloak" => true }
= render "projects/boards/switcher", board: board
......@@ -18,7 +19,7 @@
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: 'filtered-search', 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
%input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
= icon('filter')
%button.clear-search.hidden{ type: 'button' }
= icon('times')
......@@ -73,12 +74,15 @@
%li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link
Upcoming
%li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link
Started
%li.divider
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
{{title}}
#js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label' } }
#js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
......@@ -118,7 +122,7 @@
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
#js-add-issues-btn.prepend-left-10
- else
- elsif type != :boards_modal
= render 'shared/sort_dropdown'
- if @bulk_edit
......@@ -151,19 +155,20 @@
.filter-item.inline.update-issues-btn
= button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
:javascript
new UsersSelect();
new LabelsSelect();
new MilestoneSelect();
new IssueStatusSelect();
new SubscriptionSelect();
- unless type === :boards_modal
:javascript
new UsersSelect();
new LabelsSelect();
new MilestoneSelect();
new IssueStatusSelect();
new SubscriptionSelect();
$(document).off('page:restore').on('page:restore', function (event) {
if (gl.FilteredSearchManager) {
new gl.FilteredSearchManager();
}
Issuable.init();
new gl.IssuableBulkActions({
prefixId: 'issue_',
$(document).off('page:restore').on('page:restore', function (event) {
if (gl.FilteredSearchManager) {
new gl.FilteredSearchManager();
}
Issuable.init();
new gl.IssuableBulkActions({
prefixId: 'issue_',
});
});
});
......@@ -21,7 +21,7 @@
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
.issuable-form-select-holder
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
.form-group
- has_labels = @labels && @labels.any?
= form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}"
......
......@@ -6,17 +6,16 @@
- css_class = '' unless local_assigns[:css_class]
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = [project.namespace, project, controller.controller_name, controller.action_name, current_application_settings, 'v2.3']
- cache_key.push(project.commit&.sha, project.commit&.status)
- cache_key = project_list_cache_key(project)
%li.project-row{ class: css_class }
= cache(cache_key) do
.controls
- if project.archived
%span.label.label-warning archived
- if project.commit.try(:status)
- if project.pipeline_status.has_status?
%span
= render_commit_status(project.commit)
= render_project_pipeline_status(project.pipeline_status)
- if forks
%span
= icon('code-fork')
......
---
title: Fix type declarations for spend/estimate values.
merge_request:
author:
---
title: Prevent filtering issues by multiple Milestones or Authors
merge_request:
author:
---
title: Fix 500 errors caused by elasticsearch results referencing garbage-collected commits
merge_request: 1430
author:
---
title: Add support for load balancing database queries
merge_request:
author:
---
title: Speed up project dashboard by caching pipeline status and eager loading routes
merge_request: 9903
author:
---
title: Allow filtering by all started milestones
merge_request:
author:
---
title: Allow sorting by due date and priority
merge_request:
author:
---
title: Moved the gear settings dropdown to a tab in the groups view
merge_request:
author:
......@@ -9,7 +9,11 @@ production:
# username: git
# password:
# host: localhost
# port: 5432
# port: 5432
# load_balancing:
# hosts:
# - host1.example.com
# - host2.example.com
#
# Development specific
......
if Gitlab::Database::LoadBalancing.enable?
Gitlab::Database.disable_prepared_statements
Gitlab::Application.configure do |config|
config.middleware.use(Gitlab::Database::LoadBalancing::RackMiddleware)
end
Gitlab::Database::LoadBalancing.configure_proxy
end
......@@ -78,6 +78,7 @@
- [Container Registry](administration/container_registry.md) Configure Docker Registry with GitLab.
- [Repository restrictions](user/admin_area/settings/account_and_limit_settings.md#repository-size-limit) Define size restrictions for your repositories to limit the space they occupy in your storage device. Includes LFS objects.
- [Auditor users](administration/auditor_users.md) Create auditor users, with read-only access to the entire system.
- [Database load balancing](administration/database_load_balancing.md) Distribute database queries amongst multiple database servers.
## Contributor documentation
......
# Database Load Balancing
GitLab Enterprise Edition allows you to distribute read-only queries amongst
multiple database servers. This can be used to reduce the load on the primary
database, and increase responsiveness.
For load balancing to work you will need at least PostgreSQL 9.2 or newer, MySQL
is not supported. You also need to make sure that you have at least 1 secondary
in [hot standby][hot-standby] mode.
Load balancing also requires that the hosts configured in `config/database.yml`
**always** point to the primary, even after a database failover. Furthermore,
the additional hosts to balance load amongst must **always** point to secondary
databases. This means that you should put a load balance in front of every
database, and have GitLab connect to those load balancers.
For example, say you have a primary ("db1.gitlab.com") and two secondaries,
"db2.gitlab.com" and "db3.gitlab.com". For this setup you will need to have 3
load balancers, one for every host. For example:
* primary.gitlab.com forwards to db1.gitlab.com
* secondary1.gitlab.com forwards to db2.gitlab.com
* secondary2.gitlab.com forwards to db3.gitlab.com
Now let's say that a failover happens and db2 becomes the new primary. This
means forwarding should now happen as follows:
* primary.gitlab.com forwards to db2.gitlab.com
* secondary1.gitlab.com forwards to db1.gitlab.com
* secondary2.gitlab.com forwards to db3.gitlab.com
GitLab does not take care of this for you, so you will need to do so yourself.
Finally, load balancing requires that GitLab can connect to all hosts using the
same credentials and port as configured in `config/database.yml`. Using
different ports or credentials for different hosts is not supported.
## Enabling Load Balancing
Load balancing is configured in `config/database.yml`. For the environment in
which you want to use load balancing you'll need to add the following:
```yaml
load_balancing:
hosts:
- host1
- host2
- etc
```
For example, for the "production" environment:
```yaml
production:
username: gitlab
database: gitlab
encoding: unicode
load_balancing:
hosts:
- host1.example.com
- host2.example.com
```
This will balance the load between `host1.example.com` and `host2.example.com`.
## Balancing Queries
Read-only `SELECT` queries will be balanced amongst all the secondary hosts.
Everything else (including transactions) will be executed on the primary.
Queries such as `SELECT ... FOR UPDATE` are also executed on the primary.
## Prepared Statements
Prepared statements don't work well with load balancing and are disabled
automatically when load balancing is enabled. This should have no impact on
response timings.
## Primary Sticking
After a write has been performed GitLab will stick to using the primary for a
certain period of time, scoped to the user that performed the write. GitLab will
revert back to using secondaries when they have either caught up, or after 30
seconds.
## Failover Handling
In the event of a failover or an unresponsive database, the load balancer will
try to use the next available host. If no secondaries are available the
operation is performed on the primary instead.
In the event of a connection error being produced when writing data, the
operation will be retried up to 3 times using an exponential back-off.
When using load balancing you should be able to safely restart a database server
without it immediately leading to errors being presented to the users.
## Logging
The load balancer logs various messages, such as:
* When a host is marked as offline
* When a host comes back online
* When all secondaries are offline
Each log message contains the tag `[DB-LB]` to make searching/filtering of such
log entries easier.
[hot-standby]: https://www.postgresql.org/docs/9.6/static/hot-standby.html
......@@ -309,6 +309,12 @@ sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
If all items are green, then congratulations, the upgrade is complete!
### 12. Elasticsearch server update (if you currently use Elasticsearch)
In 9.0 release we bumped the required version of Elasticsearch from 2.4.x to 5.1.x.
Please update it following the official [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/5.1/setup-upgrade.html). Indexes created by Elasticsearch 2.4.x can be read by Elasticsearch 5.1.x.
## Things went south? Revert to previous version (8.17)
### 1. Revert the code to the previous version
......
# CSV Export
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1126) in [GitLab Enterprise Edition Starter](https://about.gitlab.com/products/) 9.0.
Issues can be exported as CSV from GitLab and are sent to your default notification email as an attachment.
## Choosing which issues to include
From the issues page you can narrow down which issues to export using the search bar, along with the All/Open/Closed tabs. All issues returned will be exported, including those not shown on the first page.
![CSV export button](img/csv_export_button.png)
You will be asked to confirm the number of issues and email address for the export, after which the email will begin being prepared.
![CSV export modal dialog](img/csv_export_modal.png)
## Format
Data will be encoded with a comma as the column delimiter, with `"` used to quote fields if needed, and newlines to separate rows. The first row will be the headers, which are listed in the following table along with a description of the values:
| Column | Description |
|---------|-------------|
| Issue ID | Issue `iid` |
| URL | A link to the issue on GitLab |
| Title | Issue `title` |
| State | `Open` or `Closed` |
| Description | Issue `description` |
| Author | Full name of the issue author |
| Author Username | Username of the author, with the `@` symbol omitted |
| Assignee | Full name of the issue assignee |
| Assignee Username | Username of the author, with the `@` symbol omitted |
| Confidential | `Yes` or `No` |
| Due Date | Formated as `YYYY-MM-DD` |
| Created At (UTC) | Formated as `YYYY-MM-DD HH:MM:SS` |
| Updated At (UTC) | Formated as `YYYY-MM-DD HH:MM:SS` |
| Milestone | Title of the issue milestone |
| Labels | Title of any labels joined with a `,` |
## Limitations
As the issues will be sent as an email attachment, there is a limit on how much data can be exported. Currently this limit is 20MB to ensure successful delivery across a range of email providers. If this limit is reached we suggest narrowing the search before export, perhaps by exporting open and closed issues separately.
......@@ -65,7 +65,7 @@ issues and merge requests assigned to each label.
> https://gitlab.com/gitlab-org/gitlab-ce/issues/18554.
Prioritized labels are like any other label, but sorted by priority. This allows
you to sort issues and merge requests by priority.
you to sort issues and merge requests by label priority.
To prioritize labels, navigate to your project's **Issues > Labels** and click
on the star icon next to them to put them in the priority list. Click on the
......@@ -77,9 +77,13 @@ having their priority set to null.
![Prioritize labels](img/labels_prioritize.png)
Now that you have labels prioritized, you can use the 'Priority' filter in the
issues or merge requests tracker. Those with the highest priority label, will
appear on top.
Now that you have labels prioritized, you can use the 'Priority' and 'Label
priority' filters in the issues or merge requests tracker.
The 'Label priority' filter puts issues with the highest priority label on top.
The 'Priority' filter sorts issues by their soonest milestone due date, then by
label priority.
![Filter labels by priority](img/labels_filter_by_priority.png)
......@@ -156,4 +160,3 @@ mouse over the label in the issue tracker or wherever else the label is
rendered.
![Label tooltips](img/labels_description_tooltip.png)
......@@ -9,6 +9,7 @@
- [Groups](groups.md)
- Issues - The GitLab Issue Tracker is an advanced and complete tool for
tracking the evolution of a new idea or the process of solving a problem.
- (EE) [Exporting Issues](../user/project/issues/csv_export.md) Export issues as a CSV, emailed as an attachment.
- [Confidential issues](../user/project/issues/confidential_issues.md)
- [Due date for issues](../user/project/issues/due_dates.md)
- [Issue Board](../user/project/issue_board.md)
......
# Milestones
Milestones allow you to organize issues and merge requests into a cohesive group, optionally setting a due date.
Milestones allow you to organize issues and merge requests into a cohesive group, optionally setting a due date.
A common use is keeping track of an upcoming software version. Milestones are created per-project.
![milestone form](milestones/form.png)
## Groups and milestones
You can create a milestone for several projects in the same group simultaneously.
You can create a milestone for several projects in the same group simultaneously.
On the group's milestones page, you will be able to see the status of that milestone across all of the selected projects.
![group milestone form](milestones/group_form.png)
## Special milestone filters
In addition to the milestones that exist in the project or group, there are some
special options available when filtering by milestone:
* **No Milestone** - only show issues or merge requests without a milestone.
* **Upcoming** - show issues or merge request that belong to the next open
milestone with a due date, by project. (For example: if project A has
milestone v1 due in three days, and project B has milestone v2 due in a week,
then this will show issues or merge requests from milestone v1 in project A
and milestone v2 in project B.)
* **Started** - show issues or merge requests from any milestone with a start
date less than today. Note that this can return results from several
milestones in the same project.
......@@ -46,7 +46,12 @@ class Spinach::Features::GroupsManagement < Spinach::FeatureSteps
step 'I go to group settings page' do
visit dashboard_groups_path
click_link 'Sourcing'
click_link 'Edit Group'
page.within '.layout-nav' do
click_link 'Settings'
end
page.within '.sub-nav' do
click_link 'General'
end
end
step 'I enable membership lock' do
......@@ -56,7 +61,7 @@ class Spinach::Features::GroupsManagement < Spinach::FeatureSteps
step 'I go to project settings' do
@project = Project.find_by(name: "Open")
page.within '.layout-nav' do
page.within '.sub-nav' do
click_link 'Projects'
end
......
......@@ -103,6 +103,14 @@ module Gitlab
ActiveRecord::ConnectionAdapters::ConnectionPool.new(spec)
end
# Disables prepared statements for the current database connection.
def self.disable_prepared_statements
config = ActiveRecord::Base.configurations[Rails.env]
config['prepared_statements'] = false
ActiveRecord::Base.establish_connection(config)
end
def self.connection
ActiveRecord::Base.connection
end
......
module Gitlab
module Database
module LoadBalancing
# The connection proxy to use for load balancing (if enabled).
cattr_accessor :proxy
LOG_TAG = 'DB-LB'.freeze
# The exceptions raised for connection errors.
CONNECTION_ERRORS = if defined?(PG)
[
PG::ConnectionBad,
PG::ConnectionDoesNotExist,
PG::ConnectionException,
PG::ConnectionFailure,
PG::UnableToSend,
# During a failover this error may be raised when
# writing to a primary.
PG::ReadOnlySqlTransaction
].freeze
else
[].freeze
end
# Returns the additional hosts to use for load balancing.
def self.hosts
hash = ActiveRecord::Base.configurations[Rails.env]['load_balancing']
if hash
hash['hosts'] || []
else
[]
end
end
def self.log(level, message)
Rails.logger.tagged(LOG_TAG) do
Rails.logger.send(level, message)
end
end
def self.pool_size
ActiveRecord::Base.configurations[Rails.env]['pool']
end
# Returns true if load balancing is to be enabled.
def self.enable?
program_name != 'rake' && !hosts.empty? && !Sidekiq.server? &&
Database.postgresql?
end
def self.program_name
File.basename($0)
end
# Configures proxying of requests.
def self.configure_proxy
self.proxy = ConnectionProxy.new(hosts)
# ActiveRecordProxy's methods are made available as class methods in
# ActiveRecord::Base, while still allowing the use of `super`.
ActiveRecord::Base.singleton_class.prepend(ActiveRecordProxy)
# The above will only patch newly defined models, so we also need to
# patch existing ones.
active_record_models.each do |model|
model.singleton_class.prepend(ModelProxy)
end
end
def self.active_record_models
ActiveRecord::Base.descendants
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Module injected into ActiveRecord::Base to allow proxying of subclasses.
module ActiveRecordProxy
def inherited(by)
super(by)
# The methods in ModelProxy will become available as class methods for
# the class defined in `by`.
by.singleton_class.prepend(ModelProxy)
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Redirecting of ActiveRecord connections.
#
# The ConnectionProxy class redirects ActiveRecord connection requests to
# the right load balancer pool, depending on the type of query.
class ConnectionProxy
attr_reader :load_balancer
# These methods perform writes after which we need to stick to the
# primary.
STICKY_WRITES = %i(
delete
delete_all
insert
transaction
update
update_all
).freeze
# hosts - The hosts to use for load balancing.
def initialize(hosts = [])
@load_balancer = LoadBalancer.new(hosts)
end
def select(*args)
read_using_load_balancer(:select, *args)
end
def select_all(arel, name = nil, binds = [])
if arel.respond_to?(:locked) && arel.locked
# SELECT ... FOR UPDATE queries should be sent to the primary.
write_using_load_balancer(:select_all, arel, name, binds,
sticky: true)
else
read_using_load_balancer(:select_all, arel, name, binds)
end
end
STICKY_WRITES.each do |name|
define_method(name) do |*args, &block|
write_using_load_balancer(name, *args, sticky: true, &block)
end
end
# Delegates all unknown messages to a read-write connection.
def method_missing(name, *args, &block)
write_using_load_balancer(name, *args, &block)
end
# Performs a read using the load balancer.
#
# name - The name of the method to call on a connection object.
def read_using_load_balancer(name, *args, &block)
method = Session.current.use_primary? ? :read_write : :read
@load_balancer.send(method) do |connection|
connection.send(name, *args, &block)
end
end
# Performs a write using the load balancer.
#
# name - The name of the method to call on a connection object.
# sticky - If set to true the session will stick to the master after
# the write.
def write_using_load_balancer(name, *args, sticky: false, &block)
result = @load_balancer.read_write do |connection|
# Sticking has to be enabled before calling the method. Not doing so
# could lead to methods called in a block still being performed on a
# secondary instead of on a primary (when necessary).
Session.current.use_primary! if sticky
connection.send(name, *args, &block)
end
result
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# A single database host used for load balancing.
class Host
attr_reader :pool
delegate :connection, :release_connection, to: :pool
# host - The address of the database.
def initialize(host)
@host = host
@pool = Database.create_connection_pool(LoadBalancing.pool_size, host)
@online = true
end
def offline!
LoadBalancing.log(:warn, "Marking host #{@host} as offline")
@online = false
@pool.disconnect!
end
# Returns true if the host is online.
def online?
return true if @online
begin
retried = 0
@online = begin
connection.active?
rescue
if retried < 3
release_connection
retried += 1
retry
else
false
end
end
LoadBalancing.log(:info, "Host #{@host} came back online") if @online
@online
ensure
release_connection
end
end
# Returns true if this host has caught up to the given transaction
# write location.
#
# location - The transaction write location as reported by a primary.
def caught_up?(location)
string = connection.quote(location)
# In case the host is a primary pg_last_xlog_replay_location() returns
# NULL. The recovery check ensures we treat the host as up-to-date in
# such a case.
query = "SELECT NOT pg_is_in_recovery() OR " \
"pg_xlog_location_diff(pg_last_xlog_replay_location(), #{string}) >= 0 AS result"
row = connection.select_all(query).first
row && row['result'] == 't'
ensure
release_connection
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# A list of database hosts to use for connections.
class HostList
attr_reader :hosts
# hosts - The list of secondary hosts to add.
def initialize(hosts = [])
@hosts = hosts.shuffle
@index = 0
@mutex = Mutex.new
end
def length
@hosts.length
end
# Returns the next available host.
#
# Returns a Gitlab::Database::LoadBalancing::Host instance, or nil if no
# hosts were available.
def next
@mutex.synchronize do
started_at = @index
loop do
host = @hosts[@index]
@index = (@index + 1) % @hosts.length
return host if host.online?
# Return nil once we have cycled through all hosts and none were
# available.
return if @index == started_at
end
end
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Load balancing for ActiveRecord connections.
#
# Each host in the load balancer uses the same credentials as the primary
# database.
#
# This class *requires* that `ActiveRecord::Base.connection` always
# returns a connection to the primary.
class LoadBalancer
CACHE_KEY = :gitlab_load_balancer_host
attr_reader :host_list
# hosts - The hostnames/addresses of the additional databases.
def initialize(hosts = [])
@host_list = HostList.new(hosts.map { |addr| Host.new(addr) })
end
# Yields a connection that can be used for reads.
#
# If no secondaries were available this method will use the primary
# instead.
def read(&block)
conflict_retried = 0
while host
begin
return yield host.connection
rescue => error
if serialization_failure?(error)
# This error can occur when a query conflicts. See
# https://www.postgresql.org/docs/current/static/hot-standby.html#HOT-STANDBY-CONFLICT
# for more information.
#
# In this event we'll cycle through the secondaries at most 3
# times before using the primary instead.
if conflict_retried < @host_list.length * 3
conflict_retried += 1
release_host
else
break
end
elsif connection_error?(error)
host.offline!
release_host
else
raise error
end
end
end
LoadBalancing.
log(:warn, 'No secondaries were available, using primary instead')
read_write(&block)
end
# Yields a connection that can be used for both reads and writes.
def read_write
# In the event of a failover the primary may be briefly unavailable.
# Instead of immediately grinding to a halt we'll retry the operation
# a few times.
retry_with_backoff do
yield ActiveRecord::Base.connection
end
end
# Returns a host to use for queries.
#
# Hosts are scoped per thread so that multiple threads don't
# accidentally re-use the same host + connection.
def host
RequestStore[CACHE_KEY] ||= @host_list.next
end
# Releases the host and connection for the current thread.
def release_host
RequestStore[CACHE_KEY]&.release_connection
RequestStore.delete(CACHE_KEY)
end
def release_primary_connection
ActiveRecord::Base.connection_pool.release_connection
end
# Returns the transaction write location of the primary.
def primary_write_location
read_write do |connection|
row = connection.
select_all('SELECT pg_current_xlog_insert_location()::text AS location').
first
if row
row['location']
else
raise 'Failed to determine the write location of the primary database'
end
end
end
# Returns true if all hosts have caught up to the given transaction
# write location.
def all_caught_up?(location)
@host_list.hosts.all? { |host| host.caught_up?(location) }
end
# Yields a block, retrying it upon error using an exponential backoff.
def retry_with_backoff(retries = 3, time = 2)
retried = 0
last_error = nil
while retried < retries
begin
return yield
rescue => error
raise error unless connection_error?(error)
# We need to release the primary connection as otherwise Rails
# will keep raising errors when using the connection.
release_primary_connection
last_error = error
sleep(time)
retried += 1
time **= 2
end
end
raise last_error
end
def connection_error?(error)
case error
when ActiveRecord::StatementInvalid, ActionView::Template::Error
# After connecting to the DB Rails will wrap query errors using this
# class.
connection_error?(error.original_exception)
when *CONNECTION_ERRORS
true
else
# When PG tries to set the client encoding but fails due to a
# connection error it will raise a PG::Error instance. Catching that
# would catch all errors (even those we don't want), so instead we
# check for the message of the error.
error.message.start_with?('invalid encoding name:')
end
end
def serialization_failure?(error)
if error.respond_to?(:original_exception)
serialization_failure?(error.original_exception)
else
error.is_a?(PG::TRSerializationFailure)
end
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Modle injected into models in order to redirect connections to a
# ConnectionProxy.
module ModelProxy
def connection
LoadBalancing.proxy
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Rack middleware for managing load balancing.
class RackMiddleware
SESSION_KEY = :gitlab_load_balancer
# The number of seconds after which a session should stop reading from
# the primary.
EXPIRATION = 30
def initialize(app)
@app = app
end
def call(env)
# Ensure that any state that may have run before the first request
# doesn't linger around.
clear
user = user_for_request(env)
check_primary_requirement(user) if user
result = @app.call(env)
assign_primary_for_user(user) if Session.current.use_primary? && user
result
ensure
clear
end
# Checks if we need to use the primary for the current user.
def check_primary_requirement(user)
location = last_write_location_for(user)
return unless location
if load_balancer.all_caught_up?(location)
delete_write_location_for(user)
else
Session.current.use_primary!
end
end
def assign_primary_for_user(user)
set_write_location_for(user, load_balancer.primary_write_location)
end
def clear
load_balancer.release_host
Session.clear_session
end
def load_balancer
LoadBalancing.proxy.load_balancer
end
# Returns the User object for the currently authenticated user, if any.
def user_for_request(env)
api = env['api.endpoint']
warden = env['warden']
if api && api.respond_to?(:current_user)
# The current request is an API request. In this case we can use our
# `current_user` helper method.
api.current_user
elsif warden && warden.user
# Used by the Rails app, and sometimes by the API.
warden.user
else
nil
end
end
def last_write_location_for(user)
Gitlab::Redis.with do |redis|
redis.get(redis_key_for(user))
end
end
def delete_write_location_for(user)
Gitlab::Redis.with do |redis|
redis.del(redis_key_for(user))
end
end
def set_write_location_for(user, location)
Gitlab::Redis.with do |redis|
redis.set(redis_key_for(user), location, ex: EXPIRATION)
end
end
def redis_key_for(user)
"database-load-balancing/write-location/#{user.id}"
end
end
end
end
end
module Gitlab
module Database
module LoadBalancing
# Tracking of load balancing state per user session.
#
# A session starts at the beginning of a request and ends once the request
# has been completed. Sessions can be used to keep track of what hosts
# should be used for queries.
class Session
CACHE_KEY = :gitlab_load_balancer_session
def self.current
RequestStore[CACHE_KEY] ||= new
end
def self.clear_session
RequestStore.delete(CACHE_KEY)
end
def initialize
@use_primary = false
end
def use_primary?
@use_primary
end
def use_primary!
@use_primary = true
end
end
end
end
end
......@@ -36,8 +36,6 @@ module Gitlab
merge_requests: MergeRequest.count,
milestones: Milestone.count,
notes: Note.count,
# Default scope causes this query to run for a long time
pushes: Event.unscoped.code_push.count,
pages_domains: PagesDomain.count,
projects: Project.count,
protected_branches: ProtectedBranch.count,
......
......@@ -8,6 +8,9 @@ module QA
def perform_before_hooks
EE::Scenario::License::Add.perform
rescue
Capybara::Screenshot.screenshot_and_save_page
raise
end
end
end
......
......@@ -107,6 +107,9 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'returns issues' do
page.within('.add-issues-modal') do
find('.form-control').native.send_keys(issue.title)
find('.form-control').native.send_keys(:enter)
wait_for_vue_resource
expect(page).to have_selector('.card', count: 1)
end
......@@ -115,6 +118,9 @@ describe 'Issue Boards add issue modal', :feature, :js do
it 'returns no issues' do
page.within('.add-issues-modal') do
find('.form-control').native.send_keys('testing search')
find('.form-control').native.send_keys(:enter)
wait_for_vue_resource
expect(page).not_to have_selector('.card')
expect(page).not_to have_content("You haven't added any issues to your project yet")
......
require 'rails_helper'
describe 'Issue Boards add issue modal filtering', :feature, :js do
include WaitForAjax
include WaitForVueResource
let(:project) { create(:empty_project, :public) }
......@@ -23,6 +22,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
page.within('.add-issues-modal') do
find('.form-control').native.send_keys('testing empty state')
find('.form-control').native.send_keys(:enter)
wait_for_vue_resource
......@@ -33,13 +33,11 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
it 'restores filters when closing' do
visit_board
page.within('.add-issues-modal') do
click_button 'Milestone'
wait_for_ajax
click_link 'Upcoming'
set_filter('milestone')
click_filter_link('Upcoming')
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.card', count: 0)
......@@ -56,39 +54,44 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
end
end
context 'author' do
let!(:issue) { create(:issue, project: project, author: user2) }
before do
project.team << [user2, :developer]
it 'resotres filters after clicking clear button' do
visit_board
visit_board
end
set_filter('milestone')
click_filter_link('Upcoming')
submit_filter
it 'filters by any author' do
page.within('.add-issues-modal') do
click_button 'Author'
page.within('.add-issues-modal') do
wait_for_vue_resource
wait_for_ajax
expect(page).to have_selector('.card', count: 0)
click_link 'Any Author'
find('.clear-search').click
wait_for_vue_resource
wait_for_vue_resource
expect(page).to have_selector('.card', count: 2)
end
expect(page).to have_selector('.card', count: 1)
end
end
it 'filters by selected user' do
page.within('.add-issues-modal') do
click_button 'Author'
context 'author' do
let!(:issue) { create(:issue, project: project, author: user2) }
before do
project.team << [user2, :developer]
wait_for_ajax
visit_board
end
click_link user2.name
it 'filters by selected user' do
set_filter('author')
click_filter_link(user2.name)
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: user2.username)
expect(page).to have_selector('.card', count: 1)
end
end
......@@ -103,46 +106,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
visit_board
end
it 'filters by any assignee' do
page.within('.add-issues-modal') do
click_button 'Assignee'
wait_for_ajax
click_link 'Any Assignee'
wait_for_vue_resource
expect(page).to have_selector('.card', count: 2)
end
end
it 'filters by unassigned' do
page.within('.add-issues-modal') do
click_button 'Assignee'
wait_for_ajax
click_link 'Unassigned'
set_filter('assignee')
click_filter_link('No Assignee')
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: 'none')
expect(page).to have_selector('.card', count: 1)
end
end
it 'filters by selected user' do
page.within('.add-issues-modal') do
click_button 'Assignee'
wait_for_ajax
page.within '.dropdown-menu-user' do
click_link user2.name
end
set_filter('assignee')
click_filter_link(user2.name)
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: user2.username)
expect(page).to have_selector('.card', count: 1)
end
end
......@@ -156,44 +141,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
visit_board
end
it 'filters by any milestone' do
page.within('.add-issues-modal') do
click_button 'Milestone'
wait_for_ajax
click_link 'Any Milestone'
wait_for_vue_resource
expect(page).to have_selector('.card', count: 2)
end
end
it 'filters by upcoming milestone' do
page.within('.add-issues-modal') do
click_button 'Milestone'
wait_for_ajax
click_link 'Upcoming'
set_filter('milestone')
click_filter_link('Upcoming')
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: 'upcoming')
expect(page).to have_selector('.card', count: 0)
end
end
it 'filters by selected milestone' do
page.within('.add-issues-modal') do
click_button 'Milestone'
wait_for_ajax
click_link milestone.name
set_filter('milestone')
click_filter_link(milestone.name)
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: milestone.name)
expect(page).to have_selector('.card', count: 1)
end
end
......@@ -207,44 +176,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
visit_board
end
it 'filters by any label' do
page.within('.add-issues-modal') do
click_button 'Label'
wait_for_ajax
click_link 'Any Label'
wait_for_vue_resource
expect(page).to have_selector('.card', count: 2)
end
end
it 'filters by no label' do
page.within('.add-issues-modal') do
click_button 'Label'
wait_for_ajax
click_link 'No Label'
set_filter('label')
click_filter_link('No Label')
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: 'none')
expect(page).to have_selector('.card', count: 1)
end
end
it 'filters by label' do
page.within('.add-issues-modal') do
click_button 'Label'
wait_for_ajax
click_link label.title
set_filter('label')
click_filter_link(label.title)
submit_filter
page.within('.add-issues-modal') do
wait_for_vue_resource
expect(page).to have_selector('.js-visual-token', text: label.title)
expect(page).to have_selector('.card', count: 1)
end
end
......@@ -256,4 +209,20 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
click_button('Add issues')
end
def set_filter(type, text = '')
find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}")
end
def submit_filter
find('.add-issues-modal .filtered-search').native.send_keys(:enter)
end
def click_filter_link(link_text)
page.within('.add-issues-modal .filtered-search-input-container') do
expect(page).to have_button(link_text)
click_button(link_text)
end
end
end
require 'spec_helper'
RSpec.describe 'Dashboard Projects', feature: true do
let(:user) { create(:user) }
let(:project) { create(:project, name: "awesome stuff") }
before do
login_as(create :user)
project.team << [user, :developer]
login_as user
visit dashboard_projects_path
end
it 'shows the project the user in a member of in the list' do
visit dashboard_projects_path
expect(page).to have_content('awesome stuff')
end
describe "with a pipeline" do
let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) }
before do
pipeline
end
it 'shows that the last pipeline passed' do
visit dashboard_projects_path
expect(page).to have_xpath("//a[@href='#{pipelines_namespace_project_commit_path(project.namespace, project, project.commit)}']")
end
end
it_behaves_like "an autodiscoverable RSS feed with current_user's private token"
end
......@@ -202,6 +202,14 @@ describe 'Dropdown milestone', :feature, :js do
expect_tokens([{ name: 'milestone', value: 'upcoming' }])
expect_filtered_search_input_empty
end
it 'selects `started milestones`' do
click_static_milestone('Started')
expect(page).to have_css(js_dropdown_milestone, visible: false)
expect_tokens([{ name: 'milestone', value: 'started' }])
expect_filtered_search_input_empty
end
end
describe 'input has existing content' do
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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