Commit ae66eeb0 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'master' into 'ce-to-ee-2018-06-07'

# Conflicts:
#   spec/factories/project_auto_devops.rb
parents 7e04380b fa41cf93
...@@ -87,10 +87,46 @@ export default { ...@@ -87,10 +87,46 @@ export default {
mounted() { mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({ const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: true, scroll: true,
group: 'issues',
disabled: this.disabled, disabled: this.disabled,
filter: '.board-list-count, .is-disabled', filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id', dataIdAttr: 'data-issue-id',
group: {
name: 'issues',
/**
* Dynamically determine between which containers
* items can be moved or copied as
* Assignee lists (EE feature) require this behavior
*/
pull: (to, from, dragEl, e) => {
// As per Sortable's docs, `to` should provide
// reference to exact sortable container on which
// we're trying to drag element, but either it is
// a library's bug or our markup structure is too complex
// that `to` never points to correct container
// See https://github.com/RubaXa/Sortable/issues/1037
//
// So we use `e.target` which is always accurate about
// which element we're currently dragging our card upon
// So from there, we can get reference to actual container
// and thus the container type to enable Copy or Move
if (e.target) {
const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
if (toBoardType) {
const fromBoardType = this.list.type;
if ((fromBoardType === 'assignee' && toBoardType === 'label') ||
(fromBoardType === 'label' && toBoardType === 'assignee')) {
return 'clone';
}
}
}
return true;
},
revertClone: true,
},
onStart: (e) => { onStart: (e) => {
const card = this.$refs.issue[e.oldIndex]; const card = this.$refs.issue[e.oldIndex];
...@@ -180,10 +216,11 @@ export default { ...@@ -180,10 +216,11 @@ export default {
:list="list" :list="list"
v-if="list.type !== 'closed' && showIssueForm"/> v-if="list.type !== 'closed' && showIssueForm"/>
<ul <ul
class="board-list" class="board-list js-board-list"
v-show="!loading" v-show="!loading"
ref="list" ref="list"
:data-board="list.id" :data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }"> :class="{ 'is-smaller': showIssueForm }">
<board-card <board-card
v-for="(issue, index) in issues" v-for="(issue, index) in issues"
......
...@@ -49,11 +49,12 @@ export default { ...@@ -49,11 +49,12 @@ export default {
this.error = false; this.error = false;
const labels = this.list.label ? [this.list.label] : []; const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const issue = new ListIssue({ const issue = new ListIssue({
title: this.title, title: this.title,
labels, labels,
subscribed: true, subscribed: true,
assignees: [], assignees,
project_id: this.selectedProject.id, project_id: this.selectedProject.id,
}); });
......
...@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => { ...@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true, filterable: true,
selectable: true, selectable: true,
multiSelect: true, multiSelect: true,
containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content',
clicked (options) { clicked (options) {
const { e } = options; const { e } = options;
const label = options.selectedObj; const label = options.selectedObj;
......
...@@ -7,6 +7,7 @@ import Vue from 'vue'; ...@@ -7,6 +7,7 @@ import Vue from 'vue';
import Flash from '~/flash'; import Flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import '~/vue_shared/models/label'; import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import FilteredSearchBoards from './filtered_search_boards'; import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub'; import eventHub from './eventhub';
...@@ -15,7 +16,6 @@ import './models/issue'; ...@@ -15,7 +16,6 @@ import './models/issue';
import './models/list'; import './models/list';
import './models/milestone'; import './models/milestone';
import './models/project'; import './models/project';
import './models/assignee';
import './stores/boards_store'; import './stores/boards_store';
import ModalStore from './stores/modal_store'; import ModalStore from './stores/modal_store';
import BoardService from './services/board_service'; import BoardService from './services/board_service';
......
/* eslint-disable no-unused-vars */
class ListAssignee {
constructor(user, defaultAvatar) {
this.id = user.id;
this.name = user.name;
this.username = user.username;
this.avatar = user.avatar_url || defaultAvatar;
}
}
window.ListAssignee = ListAssignee;
/* 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 */ /* 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 ListIssue */
/* global ListLabel */
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data'; import queryData from '../utils/query_data';
const PER_PAGE = 20; const PER_PAGE = 20;
class List { class List {
constructor (obj, defaultAvatar) { constructor(obj, defaultAvatar) {
this.id = obj.id; this.id = obj.id;
this._uid = this.guid(); this._uid = this.guid();
this.position = obj.position; this.position = obj.position;
...@@ -24,6 +26,9 @@ class List { ...@@ -24,6 +26,9 @@ class List {
if (obj.label) { if (obj.label) {
this.label = new ListLabel(obj.label); this.label = new ListLabel(obj.label);
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
} }
if (this.type !== 'blank' && this.type !== 'promotion' && this.id) { if (this.type !== 'blank' && this.type !== 'promotion' && this.id) {
...@@ -34,14 +39,26 @@ class List { ...@@ -34,14 +39,26 @@ class List {
} }
guid() { guid() {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); const s4 = () =>
Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
} }
save () { save() {
return gl.boardService.createList(this.label.id) const entity = this.label || this.assignee;
let entityType = '';
if (this.label) {
entityType = 'label_id';
} else {
entityType = 'assignee_id';
}
return gl.boardService
.createList(entity.id, entityType)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
this.id = data.id; this.id = data.id;
this.type = data.list_type; this.type = data.list_type;
this.position = data.position; this.position = data.position;
...@@ -50,25 +67,23 @@ class List { ...@@ -50,25 +67,23 @@ class List {
}); });
} }
destroy () { destroy() {
const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this); const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this);
gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
gl.boardService.destroyList(this.id) gl.boardService.destroyList(this.id).catch(() => {
.catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
update () { update() {
gl.boardService.updateList(this.id, this.position) gl.boardService.updateList(this.id, this.position).catch(() => {
.catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
nextPage () { nextPage() {
if (this.issuesSize > this.issues.length) { if (this.issuesSize > this.issues.length) {
if (this.issues.length / PER_PAGE >= 1) { if (this.issues.length / PER_PAGE >= 1) {
this.page += 1; this.page += 1;
...@@ -78,7 +93,7 @@ class List { ...@@ -78,7 +93,7 @@ class List {
} }
} }
getIssues (emptyIssues = true) { getIssues(emptyIssues = true) {
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page }); const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
if (this.label && data.label_name) { if (this.label && data.label_name) {
...@@ -89,9 +104,10 @@ class List { ...@@ -89,9 +104,10 @@ class List {
this.loading = true; this.loading = true;
} }
return gl.boardService.getIssuesForList(this.id, data) return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
this.loading = false; this.loading = false;
this.issuesSize = data.size; this.issuesSize = data.size;
...@@ -103,18 +119,21 @@ class List { ...@@ -103,18 +119,21 @@ class List {
}); });
} }
newIssue (issue) { newIssue(issue) {
this.addIssue(issue, null, 0); this.addIssue(issue, null, 0);
this.issuesSize += 1; this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue) return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
issue.id = data.id; issue.id = data.id;
issue.iid = data.iid; issue.iid = data.iid;
issue.milestone = data.milestone; issue.milestone = data.milestone;
issue.project = data.project; issue.project = data.project;
issue.assignees = data.assignees; issue.assignees = Array.isArray(data.assignees)
? data.assignees.map(assignee => new ListAssignee(assignee))
: data.assignees;
issue.labels = data.labels; issue.labels = data.labels;
issue.path = data.real_path; issue.path = data.real_path;
issue.referencePath = data.reference_path; issue.referencePath = data.reference_path;
...@@ -126,13 +145,13 @@ class List { ...@@ -126,13 +145,13 @@ class List {
}); });
} }
createIssues (data) { createIssues(data) {
data.forEach((issueObj) => { data.forEach(issueObj => {
this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
}); });
} }
addIssue (issue, listFrom, newIndex) { addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null; let moveBeforeId = null;
let moveAfterId = null; let moveAfterId = null;
...@@ -155,6 +174,13 @@ class List { ...@@ -155,6 +174,13 @@ class List {
issue.addLabel(this.label); issue.addLabel(this.label);
} }
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (listFrom) { if (listFrom) {
this.issuesSize += 1; this.issuesSize += 1;
...@@ -163,29 +189,29 @@ class List { ...@@ -163,29 +189,29 @@ class List {
} }
} }
moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1); this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue); this.issues.splice(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId) gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
.catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
.catch(() => { .catch(() => {
// TODO: handle request error // TODO: handle request error
}); });
} }
findIssue (id) { findIssue(id) {
return this.issues.find(issue => issue.id === id); return this.issues.find(issue => issue.id === id);
} }
removeIssue (removeIssue) { removeIssue(removeIssue) {
this.issues = this.issues.filter((issue) => { this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id; const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) { if (matchesRemove) {
......
...@@ -60,11 +60,13 @@ export default class BoardService { ...@@ -60,11 +60,13 @@ export default class BoardService {
return axios.post(this.listsEndpointGenerate, {}); return axios.post(this.listsEndpointGenerate, {});
} }
createList(labelId) { createList(entityId, entityType) {
const list = {
[entityType]: entityId,
};
return axios.post(this.listsEndpoint, { return axios.post(this.listsEndpoint, {
list: { list,
label_id: labelId,
},
}); });
} }
......
...@@ -121,8 +121,15 @@ gl.issueBoards.BoardsStore = { ...@@ -121,8 +121,15 @@ gl.issueBoards.BoardsStore = {
const listLabels = issueLists.map(listIssue => listIssue.label); const listLabels = issueLists.map(listIssue => listIssue.label);
if (!issueTo) { if (!issueTo) {
// Check if target list assignee is already present in this issue
if ((listTo.type === 'assignee' && listFrom.type === 'assignee') &&
issue.findAssignee(listTo.assignee)) {
const targetIssue = listTo.findIssue(issue.id);
targetIssue.removeAssignee(listFrom.assignee);
} else {
// Add to new lists issues if it doesn't already exist // Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex); listTo.addIssue(issue, listFrom, newIndex);
}
} else { } else {
listTo.updateIssueLabel(issue, listFrom); listTo.updateIssueLabel(issue, listFrom);
issueTo.removeLabel(listFrom.label); issueTo.removeLabel(listFrom.label);
...@@ -133,7 +140,11 @@ gl.issueBoards.BoardsStore = { ...@@ -133,7 +140,11 @@ gl.issueBoards.BoardsStore = {
list.removeIssue(issue); list.removeIssue(issue);
}); });
issue.removeLabels(listLabels); issue.removeLabels(listLabels);
} else { } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
listFrom.removeIssue(issue);
} else if ((listTo.type !== 'label' && listFrom.type === 'assignee') ||
(listTo.type !== 'assignee' && listFrom.type === 'label')) {
listFrom.removeIssue(issue); listFrom.removeIssue(issue);
} }
}, },
...@@ -144,11 +155,12 @@ gl.issueBoards.BoardsStore = { ...@@ -144,11 +155,12 @@ gl.issueBoards.BoardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
}, },
findList (key, val, type = 'label') { findList (key, val, type = 'label') {
return this.state.lists.filter((list) => { const filteredList = this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true; const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
return list[key] === val && byType; return list[key] === val && byType;
})[0]; });
return filteredList[0];
}, },
updateFiltersUrl (replaceState = false) { updateFiltersUrl (replaceState = false) {
if (replaceState) { if (replaceState) {
......
const CustomNumber = {
keydown(e) {
if (this.destroyed) return;
const { list } = e.detail.hook;
const { value } = e.detail.hook.trigger;
const parsedValue = Number(value);
const config = e.detail.hook.config.CustomNumber;
const { defaultOptions } = config;
const isValidNumber = !Number.isNaN(parsedValue) && value !== '';
const customOption = [{ id: parsedValue, title: parsedValue }];
const defaultDropdownOptions = defaultOptions.map(o => ({ id: o, title: o }));
list.setData(isValidNumber ? customOption : defaultDropdownOptions);
list.currentIndex = 0;
},
debounceKeydown(e) {
if (
[
13, // enter
16, // shift
17, // ctrl
18, // alt
20, // caps lock
37, // left arrow
38, // up arrow
39, // right arrow
40, // down arrow
91, // left window
92, // right window
93, // select
].indexOf(e.detail.which || e.detail.keyCode) > -1
)
return;
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(this.keydown.bind(this, e), 200);
},
init(hook) {
this.hook = hook;
this.destroyed = false;
this.eventWrapper = {};
this.eventWrapper.debounceKeydown = this.debounceKeydown.bind(this);
this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
},
destroy() {
if (this.timeout) clearTimeout(this.timeout);
this.destroyed = true;
this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
this.hook.trigger.removeEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
},
};
export default CustomNumber;
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import CustomNumber from '../droplab/plugins/custom_number';
export default class DropdownWeight extends FilteredSearchDropdown {
constructor(options = {}) {
super(options);
this.defaultOptions = Array.from(Array(21).keys());
this.config = {
CustomNumber: {
defaultOptions: this.defaultOptions,
},
};
}
itemClicked(e) {
super.itemClicked(e, selected => {
const title = selected.querySelector('.js-data-value').innerText.trim();
return `${DropdownUtils.getEscapedText(title)}`;
});
}
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [CustomNumber], this.config);
const defaultDropdownOptions = this.defaultOptions.map(o => ({ id: o, title: o }));
this.droplab.setData(defaultDropdownOptions);
super.renderContent(forceShowList);
}
init() {
this.droplab.addHook(this.input, this.dropdown, [CustomNumber], this.config).init();
}
}
...@@ -7,6 +7,7 @@ import DropdownHint from './dropdown_hint'; ...@@ -7,6 +7,7 @@ import DropdownHint from './dropdown_hint';
import DropdownEmoji from './dropdown_emoji'; import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user'; import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user'; import DropdownUser from './dropdown_user';
import DropdownWeight from './dropdown_weight';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager { export default class FilteredSearchDropdownManager {
...@@ -92,12 +93,12 @@ export default class FilteredSearchDropdownManager { ...@@ -92,12 +93,12 @@ export default class FilteredSearchDropdownManager {
}, },
weight: { weight: {
reference: null, reference: null,
gl: DropdownNonUser, gl: DropdownWeight,
element: this.container.querySelector('#js-dropdown-weight'), element: this.container.querySelector('#js-dropdown-weight'),
}, },
}; };
supportedTokens.forEach((type) => { supportedTokens.forEach(type => {
if (availableMappings[type]) { if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type]; allowedMappings[type] = availableMappings[type];
} }
...@@ -152,13 +153,16 @@ export default class FilteredSearchDropdownManager { ...@@ -152,13 +153,16 @@ export default class FilteredSearchDropdownManager {
updateDropdownOffset(key) { updateDropdownOffset(key) {
// Always align dropdown with the input field // Always align dropdown with the input field
let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left; let offset =
this.filteredSearchInput.getBoundingClientRect().left -
this.container.querySelector('.scroll-container').getBoundingClientRect().left;
const maxInputWidth = 240; const maxInputWidth = 240;
const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth; const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
// Make sure offset never exceeds the input container // Make sure offset never exceeds the input container
const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth; const offsetMaxWidth =
this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
if (offsetMaxWidth < offset) { if (offsetMaxWidth < offset) {
offset = offsetMaxWidth; offset = offsetMaxWidth;
} }
...@@ -184,8 +188,7 @@ export default class FilteredSearchDropdownManager { ...@@ -184,8 +188,7 @@ export default class FilteredSearchDropdownManager {
const glArguments = Object.assign({}, defaultArguments, extraArguments); const glArguments = Object.assign({}, defaultArguments, extraArguments);
// Passing glArguments to `new glClass(<arguments>)` // Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference = mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
} }
if (firstLoad) { if (firstLoad) {
...@@ -212,8 +215,8 @@ export default class FilteredSearchDropdownManager { ...@@ -212,8 +215,8 @@ export default class FilteredSearchDropdownManager {
} }
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key const shouldOpenFilterDropdown =
&& this.mapping[match.key]; match && this.currentDropdown !== match.key && this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
...@@ -224,8 +227,10 @@ export default class FilteredSearchDropdownManager { ...@@ -224,8 +227,10 @@ export default class FilteredSearchDropdownManager {
setDropdown() { setDropdown() {
const query = DropdownUtils.getSearchQuery(true); const query = DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } = const { lastToken, searchToken } = this.tokenizer.processTokens(
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys()); query,
this.filteredSearchTokenKeys.getKeys(),
);
if (this.currentDropdown) { if (this.currentDropdown) {
this.updateCurrentDropdownOffset(); this.updateCurrentDropdownOffset();
......
...@@ -602,7 +602,11 @@ GitLabDropdown = (function() { ...@@ -602,7 +602,11 @@ GitLabDropdown = (function() {
var selector; var selector;
selector = '.dropdown-content'; selector = '.dropdown-content';
if (this.dropdown.find(".dropdown-toggle-page").length) { if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one .dropdown-content"; if (this.options.containerSelector) {
selector = this.options.containerSelector;
} else {
selector = '.dropdown-page-one .dropdown-content';
}
} }
return $(selector, this.dropdown).empty(); return $(selector, this.dropdown).empty();
......
...@@ -22,4 +22,18 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -22,4 +22,18 @@ document.addEventListener('DOMContentLoaded', () => {
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint, saveEndpoint: variableListEl.dataset.saveEndpoint,
}); });
// hide extra auto devops settings based on data-attributes
const autoDevOpsSettings = document.querySelector('.js-auto-devops-settings');
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
autoDevOpsSettings.addEventListener('click', event => {
const target = event.target;
if (target.classList.contains('js-toggle-extra-settings')) {
autoDevOpsExtraSettings.classList.toggle(
'hidden',
!!(target.dataset && target.dataset.hideExtraSettings),
);
}
});
}); });
export default class ListAssignee {
constructor(obj, defaultAvatar) {
this.id = obj.id;
this.name = obj.name;
this.username = obj.username;
this.avatar = obj.avatar_url || obj.avatar || defaultAvatar;
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
}
}
window.ListAssignee = ListAssignee;
...@@ -261,3 +261,7 @@ pre code { ...@@ -261,3 +261,7 @@ pre code {
color: $white-light; color: $white-light;
} }
} }
input[type=color].form-control {
height: $input-height;
}
...@@ -215,6 +215,15 @@ ...@@ -215,6 +215,15 @@
} }
} }
} }
&.weight {
.gl-field-error {
margin-top: $gl-padding-8;
margin-left: -6px;
display: flex;
align-items: center;
}
}
} }
.block-first { .block-first {
......
...@@ -127,12 +127,16 @@ ...@@ -127,12 +127,16 @@
color: $gl-danger; color: $gl-danger;
} }
.service-settings .form-control-label { .service-settings {
padding-top: 0; input[type="radio"],
input[type="checkbox"] {
margin-top: 10px;
}
} }
.integration-settings-form { .integration-settings-form {
.card.card-body { .card.card-body,
.info-well {
padding: $gl-padding / 2; padding: $gl-padding / 2;
box-shadow: none; box-shadow: none;
} }
......
...@@ -16,3 +16,12 @@ ...@@ -16,3 +16,12 @@
.registry-placeholder { .registry-placeholder {
min-height: 60px; min-height: 60px;
} }
.auto-devops-card {
margin-bottom: $gl-vert-padding;
> .card-body {
border-radius: $card-border-radius;
padding: $gl-padding $gl-padding-24;
}
}
module Boards module Boards
class ListsController < Boards::ApplicationController class ListsController < Boards::ApplicationController
prepend ::EE::Boards::ListsController
include BoardsResponses include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate] before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
...@@ -56,8 +58,12 @@ module Boards ...@@ -56,8 +58,12 @@ module Boards
private private
def list_creation_attrs
%i[label_id]
end
def list_params def list_params
params.require(:list).permit(:label_id) params.require(:list).permit(list_creation_attrs)
end end
def move_params def move_params
...@@ -65,11 +71,15 @@ module Boards ...@@ -65,11 +71,15 @@ module Boards
end end
def serialize_as_json(resource) def serialize_as_json(resource)
resource.as_json( resource.as_json(serialization_attrs)
end
def serialization_attrs
{
only: [:id, :list_type, :position], only: [:id, :list_type, :position],
methods: [:title], methods: [:title],
label: true label: true
) }
end end
end end
end end
...@@ -96,12 +96,7 @@ module IssuableCollections ...@@ -96,12 +96,7 @@ module IssuableCollections
elsif @group elsif @group
@filter_params[:group_id] = @group.id @filter_params[:group_id] = @group.id
@filter_params[:include_subgroups] = true @filter_params[:include_subgroups] = true
else @filter_params[:use_cte_for_search] = true
# TODO: this filter ignore issues/mr created in public or
# internal repos where you are not a member. Enable this filter
# or improve current implementation to filter only issues you
# created or assigned or mentioned
# @filter_params[:authorized_only] = true
end end
@filter_params.permit(finder_type.valid_params) @filter_params.permit(finder_type.valid_params)
......
...@@ -41,7 +41,7 @@ module Projects ...@@ -41,7 +41,7 @@ module Projects
:runners_token, :builds_enabled, :build_allow_git_fetch, :runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds, :build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path, :auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled] auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy]
) )
end end
......
...@@ -3,17 +3,29 @@ class GroupMembersFinder ...@@ -3,17 +3,29 @@ class GroupMembersFinder
@group = group @group = group
end end
def execute def execute(include_descendants: false)
group_members = @group.members group_members = @group.members
wheres = []
return group_members unless @group.parent return group_members unless @group.parent || include_descendants
wheres << "members.id IN (#{group_members.select(:id).to_sql})"
if @group.parent
parents_members = GroupMember.non_request parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id)) .where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id)) .where.not(user_id: @group.users.select(:id))
wheres = ["members.id IN (#{group_members.select(:id).to_sql})"]
wheres << "members.id IN (#{parents_members.select(:id).to_sql})" wheres << "members.id IN (#{parents_members.select(:id).to_sql})"
end
if include_descendants
descendant_members = GroupMember.non_request
.where(source_id: @group.descendants.select(:id))
.where.not(user_id: @group.users.select(:id))
wheres << "members.id IN (#{descendant_members.select(:id).to_sql})"
end
GroupMember.where(wheres.join(' OR ')) GroupMember.where(wheres.join(' OR '))
end end
......
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
# created_before: datetime # created_before: datetime
# updated_after: datetime # updated_after: datetime
# updated_before: datetime # updated_before: datetime
# use_cte_for_search: boolean
# #
class IssuableFinder class IssuableFinder
prepend FinderWithCrossProjectAccess prepend FinderWithCrossProjectAccess
...@@ -54,6 +55,7 @@ class IssuableFinder ...@@ -54,6 +55,7 @@ class IssuableFinder
sort sort
state state
include_subgroups include_subgroups
use_cte_for_search
] ]
end end
...@@ -74,19 +76,21 @@ class IssuableFinder ...@@ -74,19 +76,21 @@ class IssuableFinder
items = init_collection items = init_collection
items = filter_items(items) items = filter_items(items)
# Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far # This has to be last as we may use a CTE as an optimization fence by
items = by_project(items) # passing the use_cte_for_search param
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
sort(items) sort(items)
end end
def filter_items(items) def filter_items(items)
items = by_project(items)
items = by_scope(items) items = by_scope(items)
items = by_created_at(items) items = by_created_at(items)
items = by_updated_at(items) items = by_updated_at(items)
items = by_state(items) items = by_state(items)
items = by_group(items) items = by_group(items)
items = by_search(items)
items = by_assignee(items) items = by_assignee(items)
items = by_author(items) items = by_author(items)
items = by_non_archived(items) items = by_non_archived(items)
...@@ -107,7 +111,6 @@ class IssuableFinder ...@@ -107,7 +111,6 @@ class IssuableFinder
# #
def count_by_state def count_by_state
count_params = params.merge(state: nil, sort: nil) count_params = params.merge(state: nil, sort: nil)
labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params) finder = self.class.new(current_user, count_params)
counts = Hash.new(0) counts = Hash.new(0)
...@@ -116,6 +119,11 @@ class IssuableFinder ...@@ -116,6 +119,11 @@ class IssuableFinder
# per issuable, so we have to count those in Ruby - which is bad, but still # per issuable, so we have to count those in Ruby - which is bad, but still
# better than performing multiple queries. # better than performing multiple queries.
# #
# This does not apply when we are using a CTE for the search, as the labels
# GROUP BY is inside the subquery in that case, so we set labels_count to 1.
labels_count = label_names.any? ? label_names.count : 1
labels_count = 1 if use_cte_for_search?
finder.execute.reorder(nil).group(:state).count.each do |key, value| finder.execute.reorder(nil).group(:state).count.each do |key, value|
counts[Array(key).last.to_sym] += value / labels_count counts[Array(key).last.to_sym] += value / labels_count
end end
...@@ -159,10 +167,7 @@ class IssuableFinder ...@@ -159,10 +167,7 @@ class IssuableFinder
finder_options = { include_subgroups: params[:include_subgroups], only_owned: true } finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
else else
opts = { current_user: current_user } ProjectsFinder.new(current_user: current_user).execute
opts[:project_ids_relation] = item_project_ids(items) if items
ProjectsFinder.new(opts).execute
end end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
...@@ -329,8 +334,24 @@ class IssuableFinder ...@@ -329,8 +334,24 @@ class IssuableFinder
items items
end end
def use_cte_for_search?
return false unless search
return false unless Gitlab::Database.postgresql?
params[:use_cte_for_search]
end
def by_search(items) def by_search(items)
search ? items.full_search(search) : items return items unless search
if use_cte_for_search?
cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name)
cte << items
items = klass.with(cte.to_arel).from(klass.table_name)
end
items.full_search(search)
end end
def by_iids(items) def by_iids(items)
......
...@@ -157,8 +157,4 @@ class IssuesFinder < IssuableFinder ...@@ -157,8 +157,4 @@ class IssuesFinder < IssuableFinder
[] []
end end
end end
def item_project_ids(items)
items&.reorder(nil)&.select(:project_id)
end
end end
...@@ -7,12 +7,12 @@ class MembersFinder ...@@ -7,12 +7,12 @@ class MembersFinder
@group = project.group @group = project.group
end end
def execute def execute(include_descendants: false)
project_members = project.project_members project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project) project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
if group if group
group_members = GroupMembersFinder.new(group).execute group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants)
group_members = group_members.non_invite group_members = group_members.non_invite
union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
......
...@@ -68,8 +68,4 @@ class MergeRequestsFinder < IssuableFinder ...@@ -68,8 +68,4 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch) items.where(target_branch: target_branch)
end end
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
end end
class List < ActiveRecord::Base class List < ActiveRecord::Base
prepend ::EE::List
belongs_to :board belongs_to :board
belongs_to :label belongs_to :label
enum list_type: { backlog: 0, label: 1, closed: 2 } enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 }
validates :board, :list_type, presence: true validates :board, :list_type, presence: true
validates :label, :position, presence: true, if: :label? validates :label, :position, presence: true, if: :label?
validates :label_id, uniqueness: { scope: :board_id }, if: :label? validates :label_id, uniqueness: { scope: :board_id }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :movable?
before_destroy :can_be_destroyed before_destroy :can_be_destroyed
scope :destroyable, -> { where(list_type: list_types[:label]) } scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types[:label]) } scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
class << self
def destroyable_types
[:label]
end
def movable_types
[:label]
end
end
def destroyable? def destroyable?
label? label?
......
class ProjectAutoDevops < ActiveRecord::Base class ProjectAutoDevops < ActiveRecord::Base
belongs_to :project belongs_to :project
enum deploy_strategy: {
continuous: 0,
manual: 1
}
scope :enabled, -> { where(enabled: true) } scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) } scope :disabled, -> { where(enabled: false) }
...@@ -22,6 +27,11 @@ class ProjectAutoDevops < ActiveRecord::Base ...@@ -22,6 +27,11 @@ class ProjectAutoDevops < ActiveRecord::Base
variables.append(key: 'AUTO_DEVOPS_DOMAIN', variables.append(key: 'AUTO_DEVOPS_DOMAIN',
value: domain.presence || instance_domain) value: domain.presence || instance_domain)
end end
if manual?
variables.append(key: 'STAGING_ENABLED', value: 1)
variables.append(key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 1)
end
end end
end end
......
...@@ -5,13 +5,18 @@ module Boards ...@@ -5,13 +5,18 @@ module Boards
def execute def execute
issues = IssuesFinder.new(current_user, filter_params).execute issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list? issues = filter(issues)
issues = with_list_label(issues) if movable_list?
issues.order_by_position_and_priority issues.order_by_position_and_priority
end end
private private
def filter(issues)
issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label?
issues
end
def board def board
@board ||= parent.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
...@@ -22,18 +27,6 @@ module Boards ...@@ -22,18 +27,6 @@ module Boards
@list = board.lists.find(params[:id]) if params.key?(:id) @list = board.lists.find(params[:id]) if params.key?(:id)
end end
def movable_list?
return @movable_list if defined?(@movable_list)
@movable_list = list.present? && list.movable?
end
def closed_list?
return @closed_list if defined?(@closed_list)
@closed_list = list.present? && list.closed?
end
def filter_params def filter_params
set_parent set_parent
set_state set_state
......
module Boards module Boards
module Issues module Issues
class MoveService < Boards::BaseService class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, issue) return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty? return false if issue_params(issue).empty?
update(issue) update(issue)
end end
...@@ -28,10 +30,10 @@ module Boards ...@@ -28,10 +30,10 @@ module Boards
end end
def update(issue) def update(issue)
::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue) ::Issues::UpdateService.new(issue.project, current_user, issue_params(issue)).execute(issue)
end end
def issue_params def issue_params(issue)
attrs = {} attrs = {}
if move_between_lists? if move_between_lists?
......
module Boards module Boards
module Lists module Lists
class CreateService < Boards::BaseService class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
include Gitlab::Utils::StrongMemoize
def execute(board) def execute(board)
List.transaction do List.transaction do
label = available_labels_for(board).find(params[:label_id]) target = target(board)
position = next_position(board) position = next_position(board)
create_list(board, label, position) create_list(board, type, target, position)
end end
end end
private private
def type
:label
end
def target(board)
strong_memoize(:target) do
available_labels_for(board).find(params[:label_id])
end
end
def available_labels_for(board) def available_labels_for(board)
options = { include_ancestor_groups: true } options = { include_ancestor_groups: true }
...@@ -28,8 +42,8 @@ module Boards ...@@ -28,8 +42,8 @@ module Boards
max_position.nil? ? 0 : max_position.succ max_position.nil? ? 0 : max_position.succ
end end
def create_list(board, label, position) def create_list(board, type, target, position)
board.lists.create(label: label, list_type: :label, position: position) board.lists.create(type => target, list_type: type, position: position)
end end
end end
end end
......
module Boards module Boards
module Lists module Lists
class ListService < Boards::BaseService class ListService < Boards::BaseService
prepend ::EE::Boards::Lists::ListService
def execute(board) def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
...@@ -44,8 +44,8 @@ ...@@ -44,8 +44,8 @@
= f.label :check_namespace_plan, 'Check feature availability on namespace plan', class: 'col-form-label col-sm-2' = f.label :check_namespace_plan, 'Check feature availability on namespace plan', class: 'col-form-label col-sm-2'
.col-sm-10 .col-sm-10
.form-check .form-check
= f.label :check_namespace_plan do = f.check_box :check_namespace_plan, class: 'form-check-input'
= f.check_box :check_namespace_plan = f.label :check_namespace_plan, class: 'form-check-label' do
Enabling this will only make licensed EE features available to projects if the project namespace's plan Enabling this will only make licensed EE features available to projects if the project namespace's plan
includes the feature or if the project is public. includes the feature or if the project is public.
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
.form-group.row .form-group.row
.offset-sm-2.col-sm-10 .offset-sm-2.col-sm-10
.form-check .form-check
= radio_button_tag :blacklist_type, :file, class: 'form-check-input' = radio_button_tag :blacklist_type, :file, false, class: "form-check-input"
= label_tag :blacklist_type_file, class: 'form-check-label' do = label_tag :blacklist_type_file, class: 'form-check-label' do
.option-title .option-title
Upload blacklist file Upload blacklist file
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
= s_('PrometheusService|Auto configuration') = s_('PrometheusService|Auto configuration')
- if service.manual_configuration? - if service.manual_configuration?
.card .info-well
= s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
- else - else
.container-fluid .container-fluid
......
...@@ -5,5 +5,5 @@ ...@@ -5,5 +5,5 @@
= s_('PrometheusService|Manual configuration') = s_('PrometheusService|Manual configuration')
- unless @service.editable? - unless @service.editable?
.card .info-well
= s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
.row.prepend-top-default .row
.col-lg-12 .col-lg-12
= form_for @project, url: project_settings_ci_cd_path(@project) do |f| = form_for @project, url: project_settings_ci_cd_path(@project) do |f|
= form_errors(@project) = form_errors(@project)
%fieldset.builds-feature %fieldset.builds-feature.js-auto-devops-settings
.form-group .form-group
- message = auto_devops_warning_message(@project) - message = auto_devops_warning_message(@project)
- ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe - ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe
- if message - if message
%p.settings-message.text-center %p.auto-devops-warning-message.settings-message.text-center
= message.html_safe = message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form| = f.fields_for :auto_devops_attributes, @auto_devops do |form|
.card.auto-devops-card
.card-body
.form-check .form-check
= form.radio_button :enabled, 'true', class: 'form-check-input' = form.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings'
= form.label :enabled_true, class: 'form-check-label' do = form.label :enabled_true, class: 'form-check-label' do
%strong= s_('CICD|Enable Auto DevOps') %strong= s_('CICD|Enable Auto DevOps')
%br .form-text.text-muted
= s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted } = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted }
.card.auto-devops-card
.card-body
.form-check .form-check
= form.radio_button :enabled, 'false', class: 'form-check-input' = form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings'
= form.label :enabled_false, class: 'form-check-label' do
%strong= s_('CICD|Disable Auto DevOps')
%br
= s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted }
.form-check
= form.radio_button :enabled, '', class: 'form-check-input'
= form.label :enabled_, class: 'form-check-label' do = form.label :enabled_, class: 'form-check-label' do
%strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" } %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
%br .form-text.text-muted
= s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted } = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted }
= form.label :domain, class:"prepend-top-10" do .card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil }
= _('Domain') .card-body.bg-light
= form.label :domain do
%strong= _('Domain')
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com' = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
.form-text.text-muted .form-text.text-muted
= s_('CICD|A domain is required to use Auto Review Apps and Auto Deploy Stages.') = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.')
- if cluster_ingress_ip = cluster_ingress_ip(@project) - if cluster_ingress_ip = cluster_ingress_ip(@project)
= s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe }
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank'
-# EE-specific start
.form-text.text-muted .form-text.text-muted
= s_('CICD|Do not set up a domain here if you are setting up multiple Kubernetes clusters with Auto DevOps.') = s_('CICD|Do not set up a domain here if you are setting up multiple Kubernetes clusters with Auto DevOps.')
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters'), target: '_blank' = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters'), target: '_blank'
-# EE-specific end
%label.prepend-top-10
%strong= s_('CICD|Deployment strategy')
%p.settings-message.text-center
= s_('CICD|Deployment strategy needs a domain name to work correctly.')
.form-check
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
%strong= s_('CICD|Continuous deployment to production')
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'manual', class: 'form-check-input'
= form.label :deploy_strategy_manual, class: 'form-check-label' do
%strong= s_('CICD|Automatic deployment to staging, manual deployment to production')
= link_to icon('question-circle'), help_page_path('ci/environments.md', anchor: 'manually-deploying-to-environments'), target: '_blank'
.card.auto-devops-card
.card-body
.form-check
= form.radio_button :enabled, 'false', class: 'form-check-input js-toggle-extra-settings', data: { hide_extra_settings: true }
= form.label :enabled_false, class: 'form-check-label' do
%strong= s_('CICD|Disable Auto DevOps')
.form-text.text-muted
= s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted }
= f.submit 'Save changes', class: "btn btn-success prepend-top-15" = f.submit 'Save changes', class: "btn btn-success prepend-top-15"
...@@ -9,11 +9,11 @@ ...@@ -9,11 +9,11 @@
- help = field[:help] - help = field[:help]
- disabled = disable_fields_service?(@service) - disabled = disable_fields_service?(@service)
.form-group .form-group.row
- if type == "password" && value.present? - if type == "password" && value.present?
= form.label name, "Enter new #{title.downcase}", class: "col-form-label" = form.label name, "Enter new #{title.downcase}", class: "col-form-label col-sm-2"
- else - else
= form.label name, title, class: "col-form-label" = form.label name, title, class: "col-form-label col-sm-2"
.col-sm-10 .col-sm-10
- if type == 'text' - if type == 'text'
= form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled
......
.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }', .board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
":data-id" => "list.id" } ":data-id" => "list.id" }
.board-inner .board-inner
%header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
...@@ -7,10 +7,18 @@ ...@@ -7,10 +7,18 @@
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" } "aria-hidden": "true" }
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
%span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"", %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"",
":title" => '(list.label ? list.label.description : "")', data: { container: "body" } } ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }} {{ list.title }}
%span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
":title" => '(list.assignee && list.assignee.username || "")' }
@{{ list.assignee.username }}
%span.has-tooltip{ "v-if": "list.type === \"label\"", %span.has-tooltip{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")', ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" }, data: { container: "body", placement: "bottom" },
......
- show_close = local_assigns.fetch(:show_close, true)
- subject = @project || @group - subject = @project || @group
.dropdown-page-two.dropdown-new-label .dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true }) = dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
= dropdown_content do = dropdown_content do
.dropdown-labels-error.js-label-error .dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
......
- title = local_assigns.fetch(:title, _('Assign labels')) - title = local_assigns.fetch(:title, _('Assign labels'))
- content_title = local_assigns.fetch(:content_title, _('Create lists from labels. Issues with that label appear in that list.'))
- show_title = local_assigns.fetch(:show_title, true)
- show_create = local_assigns.fetch(:show_create, true) - show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true) - show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false) - show_boards_content = local_assigns.fetch(:show_boards_content, false)
- subject = @project || @group - subject = @project || @group
.dropdown-page-one .dropdown-page-one
- if show_title
= dropdown_title(title) = dropdown_title(title)
- if show_boards_content - if show_boards_content
.issue-board-dropdown-content .issue-board-dropdown-content
%p %p
= _('Create lists from labels. Issues with that label appear in that list.') = content_title
= dropdown_filter(filter_placeholder) = dropdown_filter(filter_placeholder)
= dropdown_content = dropdown_content
- if current_board_parent && show_footer - if current_board_parent && show_footer
......
...@@ -107,17 +107,16 @@ ...@@ -107,17 +107,16 @@
- if type == :issues || type == :boards || type == :boards_modal - if type == :issues || type == :boards || type == :boards_modal
#js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ 'data-dropdown' => true } %ul{ 'data-dropdown' => true }
%li.filter-dropdown-item{ 'data-value' => 'none' } %li.filter-dropdown-item{ 'data-value' => 'None' }
%button.btn.btn-link %button.btn.btn-link
No Weight None
%li.filter-dropdown-item{ 'data-value' => 'any' } %li.filter-dropdown-item{ 'data-value' => 'Any' }
%button.btn.btn-link %button.btn.btn-link
Any Weight Any
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ 'data-dropdown' => true } %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
- Issue.weight_filter_options.each do |weight| %li.filter-dropdown-item{ data: { value: '{{id}}' } }
%li.filter-dropdown-item{ 'data-value' => "#{weight}" } %button.btn.btn-link {{title}}
%button.btn.btn-link= weight
%button.clear-search.hidden{ type: 'button' } %button.clear-search.hidden{ type: 'button' }
= icon('times') = icon('times')
...@@ -127,14 +126,7 @@ ...@@ -127,14 +126,7 @@
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list - if user_can_admin_list
.dropdown.prepend-left-10#js-add-list = render_if_exists 'shared/issuable/board_create_list_dropdown', board: board
%button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create"
= dropdown_loading
- if @project - if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn.prepend-left-10 #js-toggle-focus-btn.prepend-left-10
......
---
title: Add deploy strategies to the Auto DevOps settings
merge_request: 19172
author:
type: added
---
title: Improve performance of group issues filtering on GitLab.com
merge_request: 19429
author:
type: performance
...@@ -72,6 +72,8 @@ Rails.application.routes.draw do ...@@ -72,6 +72,8 @@ Rails.application.routes.draw do
end end
resources :issues, module: :boards, only: [:index, :update] resources :issues, module: :boards, only: [:index, :update]
resources :users, module: :boards, only: [:index]
end end
# UserCallouts # UserCallouts
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddDeployStrategyToProjectAutoDevops < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :project_auto_devops, :deploy_strategy, :integer, default: 0, allow_null: false
end
def down
remove_column :project_auto_devops, :deploy_strategy
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180605213516) do ActiveRecord::Schema.define(version: 20180607154645) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -1509,10 +1509,12 @@ ActiveRecord::Schema.define(version: 20180605213516) do ...@@ -1509,10 +1509,12 @@ ActiveRecord::Schema.define(version: 20180605213516) do
t.integer "position" t.integer "position"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id"
end end
add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree add_index "lists", ["board_id", "label_id"], name: "index_lists_on_board_id_and_label_id", unique: true, using: :btree
add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree add_index "lists", ["label_id"], name: "index_lists_on_label_id", using: :btree
add_index "lists", ["user_id"], name: "index_lists_on_user_id", using: :btree
create_table "members", force: :cascade do |t| create_table "members", force: :cascade do |t|
t.integer "access_level", null: false t.integer "access_level", null: false
...@@ -1946,6 +1948,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do ...@@ -1946,6 +1948,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "enabled" t.boolean "enabled"
t.string "domain" t.string "domain"
t.integer "deploy_strategy", default: 0, null: false
end end
add_index "project_auto_devops", ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree add_index "project_auto_devops", ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree
...@@ -2896,6 +2899,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do ...@@ -2896,6 +2899,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do
add_foreign_key "lfs_file_locks", "users", on_delete: :cascade add_foreign_key "lfs_file_locks", "users", on_delete: :cascade
add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade add_foreign_key "lists", "boards", name: "fk_0d3f677137", on_delete: :cascade
add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade add_foreign_key "lists", "labels", name: "fk_7a5553d60f", on_delete: :cascade
add_foreign_key "lists", "users", name: "fk_d6cf4279f7", on_delete: :cascade
add_foreign_key "members", "users", name: "fk_2e88fb7ce9", on_delete: :cascade add_foreign_key "members", "users", name: "fk_2e88fb7ce9", on_delete: :cascade
add_foreign_key "merge_request_diff_commits", "merge_request_diffs", on_delete: :cascade add_foreign_key "merge_request_diff_commits", "merge_request_diffs", on_delete: :cascade
add_foreign_key "merge_request_diff_files", "merge_request_diffs", on_delete: :cascade add_foreign_key "merge_request_diff_files", "merge_request_diffs", on_delete: :cascade
......
...@@ -39,13 +39,15 @@ dast: ...@@ -39,13 +39,15 @@ dast:
variables: variables:
website: "https://example.com" website: "https://example.com"
login_url: "https://example.com/sign-in" login_url: "https://example.com/sign-in"
username: "john.doe@example.com"
password: "john-doe-password"
allow_failure: true allow_failure: true
script: script:
- mkdir /zap/wrk/ - mkdir /zap/wrk/
- /zap/zap-baseline.py -J gl-dast-report.json -t $website - /zap/zap-baseline.py -J gl-dast-report.json -t $website
--auth-url $login_url --auth-url $login_url
--auth-username "john.doe@example.com" --auth-username $username
--auth-password "john-doe-password" || true --auth-password $password || true
- cp /zap/wrk/gl-dast-report.json . - cp /zap/wrk/gl-dast-report.json .
artifacts: artifacts:
paths: [gl-dast-report.json] paths: [gl-dast-report.json]
......
...@@ -40,6 +40,9 @@ organized from a broader perspective with one Issue Board per project, ...@@ -40,6 +40,9 @@ organized from a broader perspective with one Issue Board per project,
but also allow your team members to organize their own workflow by creating but also allow your team members to organize their own workflow by creating
multiple Issue Boards within the same project. multiple Issue Boards within the same project.
[GitLab Premium] adds even more powerful ways to work with Issue Boards by
allowing you to have assignee lists as well as label lists.
## Use cases ## Use cases
You can see below a few different use cases for GitLab's Issue Boards. You can see below a few different use cases for GitLab's Issue Boards.
...@@ -111,6 +114,10 @@ Cards finished by the UX team will automatically appear in the **Frontend** colu ...@@ -111,6 +114,10 @@ Cards finished by the UX team will automatically appear in the **Frontend** colu
[Codepen decided to adopt Issue Boards](https://about.gitlab.com/2017/01/27/codepen-welcome-to-gitlab/#project-management-everything-in-one-place) [Codepen decided to adopt Issue Boards](https://about.gitlab.com/2017/01/27/codepen-welcome-to-gitlab/#project-management-everything-in-one-place)
to improve their workflow with multiple boards. to improve their workflow with multiple boards.
#### Quick assignments
Create lists for each of your team members and quickly drag-and-drop issues onto each team member.
## Issue Board terminology ## Issue Board terminology
Below is a table of the definitions used for GitLab's Issue Board. Below is a table of the definitions used for GitLab's Issue Board.
...@@ -118,7 +125,8 @@ Below is a table of the definitions used for GitLab's Issue Board. ...@@ -118,7 +125,8 @@ Below is a table of the definitions used for GitLab's Issue Board.
| What we call it | What it means | | What we call it | What it means |
| -------------- | ------------- | | -------------- | ------------- |
| **Issue Board** | It represents a different view for your issues. It can have multiple lists with each list consisting of issues represented by cards. | | **Issue Board** | It represents a different view for your issues. It can have multiple lists with each list consisting of issues represented by cards. |
| **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. | | **List** | Each label that exists in the issue tracker can have its own dedicated list. Every list is named after the label it is based on and is represented by a column which contains all the issues associated with that label. You can think of a list like the results you get when you filter the issues by a label in your issue tracker. Each user in the project or group
can also have their own dedicated list. |
| **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. You can re-order cards within a list. | | **Card** | Every card represents an issue and it is shown under the list for which it has a label. The information you can see on a card consists of the issue number, the issue title, the assignee and the labels associated with it. You can drag cards around from one list to another. You can re-order cards within a list. |
There are two types of lists, the ones you create based on your labels, and There are two types of lists, the ones you create based on your labels, and
...@@ -166,7 +174,7 @@ right corner of the Issue Board. ...@@ -166,7 +174,7 @@ right corner of the Issue Board.
![Issue Board welcome message](img/issue_board_add_list.png) ![Issue Board welcome message](img/issue_board_add_list.png)
Simply choose the label to create the list from. The new list will be inserted Simply choose the label or user to create the list from. The new list will be inserted
at the end of the lists, before **Done**. Moving and reordering lists is as at the end of the lists, before **Done**. Moving and reordering lists is as
easy as dragging them around. easy as dragging them around.
......
# Issue Weight **[STARTER]** # Issue Weight **[STARTER]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/76) > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/76)
in [GitLab Starter](https://about.gitlab.com/products/) 8.3. > in [GitLab Starter](https://about.gitlab.com/products/) 8.3.
When you have a lot of issues, it can be hard to get an overview. When you have a lot of issues, it can be hard to get an overview.
By adding a weight to each issue, you can get a better idea of how much time, By adding a weight to each issue, you can get a better idea of how much time,
value or complexity a given issue has or will cost. value or complexity a given issue has or will cost.
You can set the weight of an issue during its creation, by simply changing the You can set the weight of an issue during its creation, by simply changing the
value in the dropdown menu. You can set it to a numeric value from 1 to 9. value in the dropdown menu. You can set it to a non-negative integer
value from 0, 1, 2, and so on. You can remove weight from an issue
as well.
This value will appear on the right sidebar of an individual issue, as well as This value will appear on the right sidebar of an individual issue, as well as
in the issues page next to a distinctive balance scale icon. in the issues page next to a distinctive balance scale icon.
......
doc/workflow/issue_weight/issue.png

158 KB | W: | H:

doc/workflow/issue_weight/issue.png

238 KB | W: | H:

doc/workflow/issue_weight/issue.png
doc/workflow/issue_weight/issue.png
doc/workflow/issue_weight/issue.png
doc/workflow/issue_weight/issue.png
  • 2-up
  • Swipe
  • Onion skin
<script>
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AssigneesListFilter from './assignees_list_filter.vue';
import AssigneesListContent from './assignees_list_content.vue';
export default {
components: {
LoadingIcon,
AssigneesListFilter,
AssigneesListContent,
},
props: {
loading: {
type: Boolean,
required: true,
},
assignees: {
type: Array,
required: true,
},
},
data() {
return {
query: '',
};
},
computed: {
filteredAssignees() {
if (!this.query) {
return this.assignees;
}
// fuzzaldrinPlus doesn't support filtering
// on multiple keys hence we're using plain JS.
const query = this.query.toLowerCase();
return this.assignees.filter((assignee) => {
const name = assignee.name.toLowerCase();
const username = assignee.username.toLowerCase();
return name.indexOf(query) > -1 || username.indexOf(query) > -1;
});
},
},
methods: {
handleSearch(query) {
this.query = query;
},
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
},
},
};
</script>
<template>
<div class="dropdown-assignees-list">
<div
v-if="loading"
class="dropdown-loading"
>
<loading-icon />
</div>
<assignees-list-filter
@onSearchInput="handleSearch"
/>
<assignees-list-content
v-if="!loading"
:assignees="filteredAssignees"
@onItemSelect="handleItemClick"
/>
</div>
</template>
<script>
import AssigneesListItem from './assignees_list_item.vue';
export default {
components: {
AssigneesListItem,
},
props: {
assignees: {
type: Array,
required: true,
},
},
methods: {
handleItemClick(assignee) {
this.$emit('onItemSelect', assignee);
},
},
};
</script>
<template>
<div class="dropdown-content">
<ul>
<assignees-list-item
v-for="assignee in assignees"
:key="assignee.id"
:assignee="assignee"
@onItemSelect="handleItemClick"
/>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
query: '',
};
},
methods: {
handleInputChange() {
this.$emit('onSearchInput', this.query);
},
handleInputClear() {
this.query = '';
this.handleInputChange();
},
},
};
</script>
<template>
<div
class="dropdown-input"
:class="{ 'has-value': !!query }"
>
<input
type="search"
class="dropdown-input-field"
:placeholder="__('Search')"
v-model.trim="query"
@keyup="handleInputChange"
/>
<i
class="fa fa-search dropdown-input-search"
aria-hidden="true"
data-hidden="true"
></i>
<i
role="button"
class="fa fa-times dropdown-input-clear"
aria-hidden="true"
data-hidden="true"
@click="handleInputClear"
></i>
</div>
</template>
<script>
import { sprintf, __ } from '~/locale';
export default {
props: {
assignee: {
type: Object,
required: true,
},
},
computed: {
avatarAltText() {
return sprintf(__("%{name}'s avatar"), {
name: this.assignee.name,
});
},
},
methods: {
handleItemClick() {
this.$emit('onItemSelect', this.assignee);
},
},
};
</script>
<template>
<li
class="filter-dropdown-item"
@click="handleItemClick"
>
<button
class="btn btn-link dropdown-user"
type="button"
>
<div class="avatar-container s32">
<img
class="avatar s32 lazy"
:alt="avatarAltText"
:src="assignee.avatar_url"
/>
</div>
<div class="dropdown-user-details">
<div :title="assignee.name">{{ assignee.name }}</div>
<div
class="dropdown-light-content"
:title="assignee.username"
>@{{ assignee.username }}</div>
</div>
</button>
</li>
</template>
import Vue from 'vue';
import _ from 'underscore';
import { __ } from '~/locale';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import AssigneesListContainer from './assignees_list_container.vue';
export default Vue.extend({
components: {
AssigneesListContainer,
},
props: {
listAssigneesPath: {
type: String,
required: true,
},
},
data() {
return {
loading: true,
store: gl.issueBoards.BoardsStore,
};
},
mounted() {
this.loadAssignees();
},
methods: {
loadAssignees() {
if (!this.store.state.assignees.length) {
axios
.get(this.listAssigneesPath)
.then(({ data }) => {
this.loading = false;
this.store.state.assignees = data;
})
.catch(() => {
this.loading = false;
Flash(
__('Something went wrong while fetching assignees list'),
);
});
}
},
handleItemClick(assignee) {
if (!this.store.findList('title', assignee.name)) {
this.store.new({
title: assignee.name,
position: this.store.state.lists.length - 2,
list_type: 'assignee',
user: assignee,
});
this.store.state.lists = _.sortBy(this.store.state.lists, 'position');
}
},
},
render(createElement) {
return createElement('assignees-list-container', {
props: {
loading: this.loading,
assignees: this.store.state.assignees,
},
on: {
onItemSelect: this.handleItemClick,
},
});
},
});
import Vue from 'vue'; import Vue from 'vue';
import $ from 'jquery';
import { throttle } from 'underscore'; import { throttle } from 'underscore';
import BoardForm from './board_form.vue'; import BoardForm from './board_form.vue';
import AssigneesList from './assignees_list';
(() => { (() => {
window.gl = window.gl || {}; window.gl = window.gl || {};
...@@ -34,6 +36,7 @@ import BoardForm from './board_form.vue'; ...@@ -34,6 +36,7 @@ import BoardForm from './board_form.vue';
open: false, open: false,
loading: true, loading: true,
hasScrollFade: false, hasScrollFade: false,
hasAssigneesListMounted: false,
scrollFadeInitialized: false, scrollFadeInitialized: false,
boards: [], boards: [],
state: Store.state, state: Store.state,
...@@ -91,9 +94,10 @@ import BoardForm from './board_form.vue'; ...@@ -91,9 +94,10 @@ import BoardForm from './board_form.vue';
} }
if (this.open && !this.boards.length) { if (this.open && !this.boards.length) {
gl.boardService.allBoards() gl.boardService
.allBoards()
.then(res => res.data) .then(res => res.data)
.then((json) => { .then(json => {
this.loading = false; this.loading = false;
this.boards = json; this.boards = json;
}) })
...@@ -123,9 +127,32 @@ import BoardForm from './board_form.vue'; ...@@ -123,9 +127,32 @@ import BoardForm from './board_form.vue';
this.hasScrollFade = this.isScrolledUp(); this.hasScrollFade = this.isScrolledUp();
}, },
handleDropdownHide(e) {
const $currTarget = $(e.currentTarget);
if ($currTarget.data('preventClose')) {
e.preventDefault();
}
$currTarget.removeData('preventClose');
},
handleDropdownTabClick(e) {
const $addListEl = $('#js-add-list');
$addListEl.data('preventClose', true);
if (e.target.dataset.action === 'tab-assignees' &&
!this.hasAssigneesListMounted) {
this.assigneeList = new AssigneesList({
propsData: {
listAssigneesPath: $addListEl.find('.js-new-board-list').data('listAssigneesPath'),
},
}).$mount('.js-assignees-list');
this.hasAssigneesListMounted = true;
}
},
}, },
created() { created() {
this.state.currentBoard = this.currentBoard; this.state.currentBoard = this.currentBoard;
Store.state.assignees = [];
$('#js-add-list').on('hide.bs.dropdown', this.handleDropdownHide);
$('.js-new-board-list-tabs').on('click', this.handleDropdownTabClick);
}, },
}); });
})(); })();
...@@ -6,17 +6,17 @@ const weightTokenKey = { ...@@ -6,17 +6,17 @@ const weightTokenKey = {
param: '', param: '',
symbol: '', symbol: '',
icon: 'balance-scale', icon: 'balance-scale',
tag: 'weight', tag: 'number',
}; };
const weightConditions = [{ const weightConditions = [{
url: 'weight=No+Weight', url: 'weight=None',
tokenKey: 'weight', tokenKey: 'weight',
value: 'none', value: 'None',
}, { }, {
url: 'weight=Any+Weight', url: 'weight=Any',
tokenKey: 'weight', tokenKey: 'weight',
value: 'any', value: 'Any',
}]; }];
const alternativeTokenKeys = [{ const alternativeTokenKeys = [{
......
<script> <script>
import Flash from '~/flash'; import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import weightComponent from './weight.vue'; import weightComponent from './weight.vue';
export default { export default {
components: { components: {
weight: weightComponent, weight: weightComponent,
}, },
...@@ -27,13 +27,12 @@ ...@@ -27,13 +27,12 @@
methods: { methods: {
onUpdateWeight(newWeight) { onUpdateWeight(newWeight) {
this.mediator.updateWeight(newWeight) this.mediator.updateWeight(newWeight).catch(() => {
.catch(() => {
Flash('Error occurred while updating the issue weight'); Flash('Error occurred while updating the issue weight');
}); });
}, },
}, },
}; };
</script> </script>
<template> <template>
...@@ -41,7 +40,6 @@ ...@@ -41,7 +40,6 @@
:fetching="mediator.store.isFetching.weight" :fetching="mediator.store.isFetching.weight"
:loading="mediator.store.isLoading.weight" :loading="mediator.store.isLoading.weight"
:weight="mediator.store.weight" :weight="mediator.store.weight"
:weight-options="mediator.store.weightOptions"
:weight-none-value="mediator.store.weightNoneValue" :weight-none-value="mediator.store.weightNoneValue"
:editable="mediator.store.editable" :editable="mediator.store.editable"
/> />
......
<script> <script>
/* eslint-disable vue/require-default-prop */ import $ from 'jquery';
import { s__ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import $ from 'jquery'; export default {
import { s__ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
components: { components: {
icon, icon,
loadingIcon, loadingIcon,
...@@ -28,16 +26,14 @@ ...@@ -28,16 +26,14 @@
default: false, default: false,
}, },
weight: { weight: {
type: Number, type: [String, Number],
required: false, required: false,
}, default: '',
weightOptions: {
type: Array,
required: true,
}, },
weightNoneValue: { weightNoneValue: {
type: String, type: String,
required: true, required: true,
default: 'None',
}, },
editable: { editable: {
type: Boolean, type: Boolean,
...@@ -45,14 +41,16 @@ ...@@ -45,14 +41,16 @@
default: false, default: false,
}, },
id: { id: {
type: Number, type: [String, Number],
required: false, required: false,
default: '',
}, },
}, },
data() { data() {
return { return {
shouldShowDropdown: false, hasValidInput: true,
collapseAfterDropdownCloses: false, shouldShowEditField: false,
collapsedAfterUpdate: false,
}; };
}, },
computed: { computed: {
...@@ -62,7 +60,12 @@ ...@@ -62,7 +60,12 @@
collapsedWeightLabel() { collapsedWeightLabel() {
let label = this.weight; let label = this.weight;
if (this.checkIfNoValue(this.weight)) { if (this.checkIfNoValue(this.weight)) {
label = s__('Sidebar|No'); label = this.noValueLabel;
}
// Truncate with ellipsis after five digits
if (this.weight > 99999) {
label = `${this.weight.toString().substr(0, 5)}&hellip;`;
} }
return label; return label;
...@@ -82,7 +85,7 @@ ...@@ -82,7 +85,7 @@
return label; return label;
}, },
shouldShowWeight() { shouldShowWeight() {
return !this.fetching && !this.shouldShowDropdown; return !this.fetching && !this.shouldShowEditField;
}, },
tooltipTitle() { tooltipTitle() {
let tooltipTitle = s__('Sidebar|Weight'); let tooltipTitle = s__('Sidebar|Weight');
...@@ -94,66 +97,51 @@ ...@@ -94,66 +97,51 @@
return tooltipTitle; return tooltipTitle;
}, },
}, },
mounted() {
$(this.$refs.weightDropdown).glDropdown({
showMenuAbove: false,
selectable: true,
filterable: false,
multiSelect: false,
data: (searchTerm, callback) => {
callback(this.weightOptions);
},
renderRow: (weight) => {
const isActive = weight === this.weight ||
(this.checkIfNoValue(weight) && this.checkIfNoValue(this.weight));
return `
<li>
<a href="#" class="${isActive ? 'is-active' : ''}">
${weight}
</a>
</li>
`;
},
hidden: () => {
this.shouldShowDropdown = false;
this.collapseAfterDropdownCloses = false;
},
clicked: (options) => {
const selectedValue = this.checkIfNoValue(options.selectedObj) ?
null :
options.selectedObj;
const resultantValue = options.isMarking ? selectedValue : null;
eventHub.$emit('updateWeight', resultantValue, this.id);
},
});
},
methods: { methods: {
checkIfNoValue(weight) { checkIfNoValue(weight) {
return weight === undefined || return weight === undefined || weight === null || weight === this.weightNoneValue;
weight === null ||
weight === 0 ||
weight === this.weightNoneValue;
}, },
showDropdown() { showEditField(bool = true) {
this.shouldShowDropdown = true; this.shouldShowEditField = bool;
// Trigger the bootstrap dropdown
setTimeout(() => { if (this.shouldShowEditField) {
$(this.$refs.dropdownToggle).dropdown('toggle'); this.$nextTick(() => {
this.$refs.editableField.focus();
}); });
}
}, },
onCollapsedClick() { onCollapsedClick() {
this.collapseAfterDropdownCloses = true; this.showEditField(true);
this.showDropdown(); this.collapsedAfterUpdate = true;
}, },
onSubmit(e) {
const { value } = e.target;
const validatedValue = Number(value);
const isNewValue = validatedValue !== this.weight;
this.hasValidInput = validatedValue >= 0 || value === '';
if (!this.loading && this.hasValidInput) {
$(this.$el).trigger('hidden.gl.dropdown');
if (isNewValue) {
eventHub.$emit('updateWeight', value, this.id);
}
this.showEditField(false);
}
}, },
}; removeWeight() {
eventHub.$emit('updateWeight', '', this.id);
},
},
};
</script> </script>
<template> <template>
<div <div
class="block weight" class="block weight"
:class="{ 'collapse-after-update': collapseAfterDropdownCloses }" :class="{ 'collapse-after-update': collapsedAfterUpdate }"
> >
<div <div
class="sidebar-collapsed-icon js-weight-collapsed-block" class="sidebar-collapsed-icon js-weight-collapsed-block"
...@@ -174,10 +162,9 @@ ...@@ -174,10 +162,9 @@
/> />
<span <span
v-else v-else
v-html="collapsedWeightLabel"
class="js-weight-collapsed-weight-label" class="js-weight-collapsed-weight-label"
> ></span>
{{ collapsedWeightLabel }}
</span>
</div> </div>
<div class="title hide-collapsed"> <div class="title hide-collapsed">
{{ s__('Sidebar|Weight') }} {{ s__('Sidebar|Weight') }}
...@@ -190,76 +177,55 @@ ...@@ -190,76 +177,55 @@
v-if="editable" v-if="editable"
class="float-right js-weight-edit-link" class="float-right js-weight-edit-link"
href="#" href="#"
@click="showDropdown" @click="showEditField(!shouldShowEditField)"
> >
{{ __('Edit') }} {{ __('Edit') }}
</a> </a>
</div> </div>
<div <div
v-if="shouldShowWeight" class="hide-collapsed"
class="value hide-collapsed js-weight-weight-label" v-if="shouldShowEditField"
> >
<strong v-if="!isNoValue"> <input
{{ weight }} class="form-control"
</strong> type="text"
ref="editableField"
:value="weight"
@blur="onSubmit"
@keydown.enter="onSubmit"
/>
<span <span
v-else class="gl-field-error"
class="no-value"> v-if="!hasValidInput"
{{ noValueLabel }} >
<icon
name="merge-request-close-m"
:size="24"
/>
{{ s__('Sidebar|Only numeral characters allowed') }}
</span> </span>
</div> </div>
<div <div
class="selectbox hide-collapsed" v-if="shouldShowWeight"
:class="{ show: shouldShowDropdown }" class="value hide-collapsed js-weight-weight-label"
>
<div
ref="weightDropdown"
class="dropdown"
>
<button
ref="dropdownToggle"
class="dropdown-menu-toggle js-gl-dropdown-refresh-on-open"
type="button"
data-toggle="dropdown"
> >
<span <span v-if="!isNoValue">
class="dropdown-toggle-text js-weight-dropdown-toggle-text" <strong class="js-weight-weight-label-value">{{ weight }}</strong>
:class="{ 'is-default': isNoValue }" &nbsp;-&nbsp;
<a
v-if="editable"
class="btn-default-hover-link js-weight-remove-link"
href="#"
@click="removeWeight"
> >
{{ dropdownToggleLabel }} {{ __('remove weight') }}
</a>
</span> </span>
<i <span
aria-hidden="true" v-else
data-hidden="true" class="no-value">
class="fa fa-chevron-down" {{ noValueLabel }}
>
</i>
</button>
<div
v-once
class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-weight"
>
<div class="dropdown-title">
<span>
{{ changeWeightLabel }}
</span> </span>
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
>
</i>
</button>
</div>
<div class="dropdown-content js-weight-dropdown-content"></div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -4,3 +4,108 @@ ...@@ -4,3 +4,108 @@
top: 3px; top: 3px;
} }
} }
.dropdown.show .dropdown-menu.dropdown-menu-tabs {
max-height: 400px;
overflow-y: hidden;
}
.dropdown .dropdown-menu.dropdown-menu-tabs {
padding-top: 0;
width: 240px;
.dropdown-tabs-list {
display: flex;
box-shadow: 0 0 0 1px $border-color;
.dropdown-tab-item {
flex: 1;
border-left: 1px solid $border-color;
&:first-of-type {
border-left: 0;
}
a {
width: 100%;
padding: $gl-padding $gl-padding-top;
text-align: center;
border-bottom: 2px solid transparent;
background-color: $gray-light;
&:focus,
&.active {
background-color: $white-light;
}
&.active {
font-weight: bold;
border-bottom-color: $indigo-500;
}
}
}
}
.tab-content {
.issue-board-dropdown-content {
margin: 0;
padding: $gl-padding;
border-bottom: 0;
color: $gl-text-color-secondary;
}
.tab-pane-labels {
.dropdown-page-one .dropdown-content {
height: 140px;
}
.dropdown-page-two {
margin-top: 10px;
.dropdown-content {
max-height: initial;
height: 205px;
}
}
}
.tab-pane-assignees {
.dropdown-content {
height: 225px;
max-height: 252px;
}
.dropdown-user {
display: flex;
padding: $gl-padding-8 $gl-padding-24;
}
.dropdown-user-details div {
max-width: 130px;
text-overflow: ellipsis;
overflow: hidden;
}
.dropdown-loading {
display: block;
}
}
}
}
.board-type-assignee {
.board-title-text,
.board-title-sub-text {
@include str-truncated(110px);
}
.board-title-text {
margin-right: 0;
}
.board-title-sub-text {
margin-right: auto;
color: $gl-text-color-secondary;
font-weight: normal;
}
}
module Boards
class UsersController < Boards::ApplicationController
# Enumerates all users that are members of the board parent
# If board parent is a project it only enumerates project members
# If board parent is a group it enumerates all members of current group,
# ancestors, and descendants
def index
user_ids = finder_service
.execute(include_descendants: true)
.non_invite
.select(:user_id)
users = User.where(id: user_ids)
render json: UserSerializer.new.represent(users)
end
private
def finder_service
@service ||=
if board_parent.is_a?(Group)
GroupMembersFinder.new(board_parent)
else
MembersFinder.new(board_parent, current_user)
end
end
end
end
module EE
module Boards
module ListsController
extend ::Gitlab::Utils::Override
override :list_creation_attrs
def list_creation_attrs
super + %i[assignee_id]
end
override :serialization_attrs
def serialization_attrs
super.merge(user: true)
end
end
end
end
module EE module EE
module BoardsHelper module BoardsHelper
extend ::Gitlab::Utils::Override
def parent def parent
@group || @project @group || @project
end end
override :board_list_data
def board_list_data
super.merge(list_assignees_path: board_users_path(board, :json))
end
override :board_data
def board_data def board_data
show_feature_promotion = (@project && show_promotions? && show_feature_promotion = (@project && show_promotions? &&
(!@project.feature_available?(:multiple_project_issue_boards) || (!@project.feature_available?(:multiple_project_issue_boards) ||
...@@ -37,6 +45,7 @@ module EE ...@@ -37,6 +45,7 @@ module EE
) )
end end
override :boards_link_text
def boards_link_text def boards_link_text
if parent.multiple_issue_boards_available? if parent.multiple_issue_boards_available?
s_("IssueBoards|Boards") s_("IssueBoards|Boards")
......
...@@ -4,13 +4,15 @@ module EE ...@@ -4,13 +4,15 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
prepended do prepended do
WEIGHT_RANGE = 1..9 WEIGHT_RANGE = 0..20
WEIGHT_ALL = 'Everything'.freeze WEIGHT_ALL = 'Everything'.freeze
WEIGHT_ANY = 'Any Weight'.freeze WEIGHT_ANY = 'Any'.freeze
WEIGHT_NONE = 'No Weight'.freeze WEIGHT_NONE = 'None'.freeze
scope :order_weight_desc, -> { reorder ::Gitlab::Database.nulls_last_order('weight', 'DESC') } scope :order_weight_desc, -> { reorder ::Gitlab::Database.nulls_last_order('weight', 'DESC') }
scope :order_weight_asc, -> { reorder ::Gitlab::Database.nulls_last_order('weight') } scope :order_weight_asc, -> { reorder ::Gitlab::Database.nulls_last_order('weight') }
validates :weight, allow_nil: true, numericality: { greater_than_or_equal_to: 0 }
end end
# override # override
......
module EE
module List
extend ::Gitlab::Utils::Override
# ActiveSupport::Concern does not prepend the ClassMethods,
# so we cannot call `super` if we use it.
def self.prepended(base)
class << base
prepend ClassMethods
end
base.belongs_to :user
base.validates :user, presence: true, if: :assignee?
base.validates :user_id, uniqueness: { scope: :board_id }, if: :assignee?
base.validates :list_type,
exclusion: { in: %w[assignee], message: _('Assignee boards not available with your current license') },
unless: -> { board&.parent&.feature_available?(:board_assignee_lists) }
end
def assignee=(user)
self.user = user
end
override :destroyable?
def destroyable?
assignee? || super
end
override :movable?
def movable?
assignee? || super
end
override :title
def title
assignee? ? user.to_reference : super
end
override :as_json
def as_json(options = {})
super.tap do |json|
if options.key?(:user)
json[:user] = UserSerializer.new.represent(user).as_json
end
end
end
module ClassMethods
def destroyable_types
super + [:assignee]
end
def movable_types
super + [:assignee]
end
end
end
end
...@@ -36,6 +36,7 @@ class License < ActiveRecord::Base ...@@ -36,6 +36,7 @@ class License < ActiveRecord::Base
EEP_FEATURES = EES_FEATURES + %i[ EEP_FEATURES = EES_FEATURES + %i[
admin_audit_log admin_audit_log
auditor_user auditor_user
board_assignee_lists
cross_project_pipelines cross_project_pipelines
email_additional_text email_additional_text
db_load_balancing db_load_balancing
......
...@@ -6,13 +6,14 @@ module EE ...@@ -6,13 +6,14 @@ module EE
override :issue_params override :issue_params
def issue_params def issue_params
assignee_ids = Array(list.user_id || board.assignee&.id)
{ {
label_ids: [list.label_id, *board.label_ids], label_ids: [list.label_id, *board.label_ids],
weight: board.weight, weight: board.weight,
milestone_id: board.milestone_id, milestone_id: board.milestone_id,
# This can be removed when boards have multiple assignee support. # This can be removed when boards have multiple assignee support.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786 # See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
assignee_ids: Array(board.assignee&.id) assignee_ids: assignee_ids
} }
end end
end end
......
...@@ -2,6 +2,18 @@ module EE ...@@ -2,6 +2,18 @@ module EE
module Boards module Boards
module Issues module Issues
module ListService module ListService
extend ::Gitlab::Utils::Override
override :filter
def filter(issues)
issues = without_board_assignees(issues) unless list&.movable? || list&.closed?
return super unless list&.assignee?
with_assignee(super)
end
override :issues_label_links
def issues_label_links def issues_label_links
if has_valid_milestone? if has_valid_milestone?
super.where("issues.milestone_id = ?", board.milestone_id) super.where("issues.milestone_id = ?", board.milestone_id)
...@@ -12,6 +24,25 @@ module EE ...@@ -12,6 +24,25 @@ module EE
private private
def board_assignee_ids
@board_assignee_ids ||=
if parent.feature_available?(:board_assignee_lists)
board.lists.movable.pluck(:user_id).compact
else
[]
end
end
def without_board_assignees(issues)
return issues unless board_assignee_ids.any?
issues.where.not(id: issues.joins(:assignees).where(users: { id: board_assignee_ids }))
end
def with_assignee(issues)
issues.assigned_to(list.user)
end
# Prevent filtering by milestone stubs # Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc # like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone? def has_valid_milestone?
......
module EE
module Boards
module Issues
module MoveService
extend ::Gitlab::Utils::Override
override :issue_params
def issue_params(issue)
return super unless move_between_lists?
args = super
unless both_are_same_type? || !moving_to_list.movable?
args.delete(:remove_label_ids)
end
args.merge(assignee_ids: assignee_ids(issue))
end
def both_are_list_type?(type)
return false unless moving_from_list.list_type == type
both_are_same_type?
end
def both_are_same_type?
moving_from_list.list_type == moving_to_list.list_type
end
def assignee_ids(issue)
assignees = (issue.assignee_ids + [moving_to_list.user_id]).compact
assignees -= [moving_from_list.user_id] if both_are_list_type?('assignee') || moving_to_list.backlog?
assignees
end
end
end
end
end
module EE
module Boards
module Lists
module CreateService
extend ::Gitlab::Utils::Override
override :type
def type
return :assignee if params.keys.include?('assignee_id')
super
end
override :target
def target(board)
strong_memoize(:target) do
case type
when :assignee
find_user(board)
else
super
end
end
end
def find_user(board)
user_ids = user_finder(board).execute(include_descendants: true).non_invite.select(:user_id)
::User.where(id: user_ids).find(params[:assignee_id])
end
def user_finder(board)
@service ||=
if board.parent.is_a?(Group)
GroupMembersFinder.new(board.parent)
else
MembersFinder.new(board.parent, current_user)
end
end
end
end
end
end
module EE
module Boards
module Lists
module ListService
extend ::Gitlab::Utils::Override
override :execute
def execute(board)
return super if board.parent.feature_available?(:board_assignee_lists)
super.where.not(list_type: ::List.list_types[:assignee])
end
end
end
end
end
...@@ -21,13 +21,13 @@ module EE ...@@ -21,13 +21,13 @@ module EE
explanation do |weight| explanation do |weight|
"Sets weight to #{weight}." if weight "Sets weight to #{weight}." if weight
end end
params ::Issue::WEIGHT_RANGE.to_s.squeeze('.').tr('.', '-') params "0, 1, 2, …"
condition do condition do
issuable.supports_weight? && issuable.supports_weight? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end end
parse_params do |weight| parse_params do |weight|
weight.to_i if ::Issue.weight_filter_options.include?(weight.to_i) weight.to_i if weight.to_i > 0
end end
command :weight do |weight| command :weight do |weight|
@updates[:weight] = weight if weight @updates[:weight] = weight if weight
......
...@@ -167,8 +167,9 @@ module Geo ...@@ -167,8 +167,9 @@ module Geo
attrs["resync_#{type}"] = true attrs["resync_#{type}"] = true
attrs["last_#{type}_sync_failure"] = "#{message}: #{error.message}" attrs["last_#{type}_sync_failure"] = "#{message}: #{error.message}"
attrs["#{type}_retry_count"] = retry_count + 1 attrs["#{type}_retry_count"] = retry_count + 1
registry.update!(attrs) registry.update!(attrs)
repository.clean_stale_repository_files
end end
def type def type
......
...@@ -3,22 +3,22 @@ ...@@ -3,22 +3,22 @@
%fieldset.system_header_footer %fieldset.system_header_footer
%legend %legend
= _('System header and footer:') = _('System header and footer:')
.form-group .form-group.row
= form.label :header_message, _('Header message'), class: 'col-form-label' = form.label :header_message, _('Header message'), class: 'col-sm-2 col-form-label'
.col-sm-10 .col-sm-10
= form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
.form-group .form-group.row
= form.label :footer_message, _('Footer message'), class: 'col-form-label' = form.label :footer_message, _('Footer message'), class: 'col-sm-2 col-form-label'
.col-sm-10 .col-sm-10
= form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
.form-group.js-toggle-colors-container .form-group.row.js-toggle-colors-container
.col-sm-10.offset-sm-2 .col-sm-10.offset-sm-2
= link_to _('Customize colors'), '#', class: 'js-toggle-colors-link' = link_to _('Customize colors'), '#', class: 'js-toggle-colors-link'
.form-group.js-toggle-colors-container.hide .form-group.row.js-toggle-colors-container.hide
= form.label :message_background_color, _('Background Color'), class: 'col-form-label' = form.label :message_background_color, _('Background Color'), class: 'col-sm-2 col-form-label'
.col-sm-10 .col-sm-10
= form.color_field :message_background_color, class: "form-control" = form.color_field :message_background_color, class: "form-control"
.form-group.js-toggle-colors-container.hide .form-group.row.js-toggle-colors-container.hide
= form.label :message_font_color, _('Font Color'), class: 'col-form-label' = form.label :message_font_color, _('Font Color'), class: 'col-sm-2 col-form-label'
.col-sm-10 .col-sm-10
= form.color_field :message_font_color, class: "form-control" = form.color_field :message_font_color, class: "form-control"
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
- form = local_assigns.fetch(:form) - form = local_assigns.fetch(:form)
.form-group .form-group.row
= form.label :repository_size_limit, class: 'col-form-label col-sm-2' do = form.label :repository_size_limit, class: 'col-form-label col-sm-2' do
Size limit per repository (MB) Size limit per repository (MB)
.col-sm-10 .col-sm-10
......
.form-group .form-group.row
= form.label :shared_runners_minutes, 'Pipeline minutes quota', class: 'col-form-label col-sm-2' = form.label :shared_runners_minutes, 'Pipeline minutes quota', class: 'col-form-label col-sm-2'
.col-sm-10 .col-sm-10
= form.number_field :shared_runners_minutes, class: 'form-control' = form.number_field :shared_runners_minutes, class: 'form-control'
......
- return unless License.feature_available?(:project_creation_level) - return unless License.feature_available?(:project_creation_level)
- form = local_assigns.fetch(:form) - form = local_assigns.fetch(:form)
- application_setting = local_assigns.fetch(:application_setting) - application_setting = local_assigns.fetch(:application_setting)
.form-group .form-group.row
= form.label s_('ProjectCreationLevel|Default project creation protection'), class: 'col-form-label col-sm-2' = form.label s_('ProjectCreationLevel|Default project creation protection'), class: 'col-form-label col-sm-2'
.col-sm-10 .col-sm-10
= form.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, application_setting.default_project_creation), {}, class: 'form-control' = form.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, application_setting.default_project_creation), {}, class: 'form-control'
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- type = local_assigns.fetch(:type) - type = local_assigns.fetch(:type)
- label_class = (type == :project) ? 'label-light' : 'col-form-label' - label_class = (type == :project) ? 'label-light' : 'col-form-label'
.form-group .form-group.row
= form.label :repository_size_limit, class: label_class do = form.label :repository_size_limit, class: label_class do
Repository size limit (MB) Repository size limit (MB)
- if type == :project - if type == :project
......
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
.dropdown-page-one
.issue-board-dropdown-content
%p
= _('Assignee lists show all issues assigned to the selected user.')
.js-assignees-list
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-create.btn-inverted.d-flex.js-new-board-list{ type: "button", data: board_list_data }
%span Add list
= sprite_icon('chevron-down', size: 16, css_class: 'prepend-left-5 btn-new-board-list-chevron')
.dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.dropdown-menu-tabs
%ul.nav.nav-tabs.dropdown-tabs-list.js-new-board-list-tabs{ role: 'tablist' }
%li.nav-item.dropdown-tab-item.js-tab-button-labels
%a.active{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-labels', target: '#tab-labels' } }
Label list
%li.nav-item.dropdown-tab-item.js-tab-button-labels
%a{ href: '#', role: 'tab', data: { is_link: 'true', toggle: 'tab', action: 'tab-assignees', target: '#tab-assignees' } }
Assignee list
.tab-content
#tab-labels.tab-pane.tab-pane-labels.active.js-tab-container-labels{ role: 'tabpanel' }
= render partial: "shared/issuable/label_page_default", locals: { show_title: false, show_footer: true, show_create: true, show_boards_content: true, content_title: _('Label lists show all issues with the selected label.') }
- if can?(current_user, :admin_label, board.parent)
= render partial: "shared/issuable/label_page_create", locals: { show_close: false }
#tab-assignees.tab-pane.tab-pane-assignees.js-tab-container-assignees{ role: 'tabpanel' }
= render partial: "shared/issuable/assignee_page_default"
= dropdown_loading
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
- form = local_assigns.fetch(:form) - form = local_assigns.fetch(:form)
.form-group.row .form-group.row
= form.label :label_ids, class: "col-form-label #{"col-lg-4" if has_due_date}" do = form.label :label_ids, class: "col-form-label col-md-2 #{"col-lg-4" if has_due_date}" do
Weight Weight
.col-sm-10{ class: ("col-lg-8" if has_due_date) } .col-md-10{ class: ("col-lg-8" if has_due_date) }
.issuable-form-select-holder .issuable-form-select-holder
- if issuable.weight - if issuable.weight
= form.hidden_field :weight = form.hidden_field :weight
......
---
title: Add support for non-negative integer weight values in issuable sidebar
merge_request:
author:
type: changed
---
title: 'Geo: Automatically clean up stale lock files on Geo secondary'
merge_request: 6034
author:
type: fixed
---
title: Add assignee board list type
merge_request: 5743
author:
type: added
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :lists, :user_id, :integer
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserIndexToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_concurrent_index :lists, :user_id
end
def down
remove_concurrent_index :lists, :user_id
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUserFkToList < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index", "remove_concurrent_index" or
# "add_column_with_default" you must disable the use of transactions
# as these methods can not run in an existing transaction.
# When using "add_concurrent_index" or "remove_concurrent_index" methods make sure
# that either of them is the _only_ method called in the migration,
# any other changes should go in a separate migration.
# This ensures that upon failure _only_ the index creation or removing fails
# and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
disable_ddl_transaction!
def up
add_concurrent_foreign_key :lists, :users, column: :user_id, on_delete: :cascade
end
def down
remove_foreign_key :lists, column: :user_id
end
end
...@@ -26,7 +26,7 @@ describe Boards::ListsController do ...@@ -26,7 +26,7 @@ describe Boards::ListsController do
parsed_response = JSON.parse(response.body) parsed_response = JSON.parse(response.body)
expect(response).to match_response_schema('lists') expect(response).to match_response_schema('lists', dir: 'ee')
expect(parsed_response.length).to eq 3 expect(parsed_response.length).to eq 3
end end
...@@ -62,7 +62,7 @@ describe Boards::ListsController do ...@@ -62,7 +62,7 @@ describe Boards::ListsController do
it 'returns the created list' do it 'returns the created list' do
create_board_list user: user, board: board, label_id: label.id create_board_list user: user, board: board, label_id: label.id
expect(response).to match_response_schema('list') expect(response).to match_response_schema('list', dir: 'ee')
end end
end end
......
require 'spec_helper'
describe Boards::UsersController do
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
let(:guest) { create(:user) }
let(:user) { create(:user) }
before do
group.add_master(user)
group.add_guest(guest)
sign_in(user)
end
describe 'GET index' do
it 'returns a list of all members of board parent' do
get :index, namespace_id: group.to_param,
board_id: board.to_param,
format: :json
parsed_response = JSON.parse(response.body)
expect(response).to have_gitlab_http_status(200)
expect(response.content_type).to eq 'application/json'
expect(parsed_response).to all(match_schema('entities/user'))
expect(parsed_response.length).to eq 2
end
end
end
FactoryBot.define do
factory :user_list, parent: :list do
list_type :assignee
label nil
user
end
end
...@@ -170,8 +170,8 @@ describe 'Scoped issue boards', :js do ...@@ -170,8 +170,8 @@ describe 'Scoped issue boards', :js do
end end
end end
it 'creates board filtering by "Any weight"' do it 'creates board filtering by "Any" weight' do
create_board_weight('Any Weight') create_board_weight('Any')
expect(page).to have_selector('.board-card', count: 4) expect(page).to have_selector('.board-card', count: 4)
end end
...@@ -356,7 +356,7 @@ describe 'Scoped issue boards', :js do ...@@ -356,7 +356,7 @@ describe 'Scoped issue boards', :js do
end end
it 'sets board to Any weight' do it 'sets board to Any weight' do
update_board_weight('Any Weight') update_board_weight('Any')
expect(page).to have_selector('.board-card', count: 4) expect(page).to have_selector('.board-card', count: 4)
end end
......
...@@ -186,7 +186,7 @@ describe 'Issue Boards', :js do ...@@ -186,7 +186,7 @@ describe 'Issue Boards', :js do
page.within '.weight' do page.within '.weight' do
click_link 'Edit' click_link 'Edit'
click_link '1' find('.block.weight input').send_keys 1, :enter
page.within '.value' do page.within '.value' do
expect(page).to have_content '1' expect(page).to have_content '1'
...@@ -212,8 +212,7 @@ describe 'Issue Boards', :js do ...@@ -212,8 +212,7 @@ describe 'Issue Boards', :js do
wait_for_requests wait_for_requests
page.within '.weight' do page.within '.weight' do
click_link 'Edit' click_link 'remove weight'
click_link 'No Weight'
page.within '.value' do page.within '.value' do
expect(page).to have_content 'None' expect(page).to have_content 'None'
......
...@@ -17,7 +17,7 @@ describe 'Dropdown weight', :js do ...@@ -17,7 +17,7 @@ describe 'Dropdown weight', :js do
end end
def click_weight(text) def click_weight(text)
find('#js-dropdown-weight .filter-dropdown .filter-dropdown-item', text: text).click find('#js-dropdown-weight .filter-dropdown .filter-dropdown-item', text: text, exact_text: true).click
end end
def click_static_weight(text) def click_static_weight(text)
...@@ -49,7 +49,7 @@ describe 'Dropdown weight', :js do ...@@ -49,7 +49,7 @@ describe 'Dropdown weight', :js do
it 'should load all the weights when opened' do it 'should load all the weights when opened' do
send_keys_to_filtered_search('weight:') send_keys_to_filtered_search('weight:')
expect(page.all('#js-dropdown-weight .filter-dropdown .filter-dropdown-item').size).to eq(9) expect(page.all('#js-dropdown-weight .filter-dropdown .filter-dropdown-item').size).to eq(21)
end end
end end
...@@ -83,10 +83,10 @@ describe 'Dropdown weight', :js do ...@@ -83,10 +83,10 @@ describe 'Dropdown weight', :js do
end end
it 'fills in `no weight`' do it 'fills in `no weight`' do
click_static_weight('No Weight') click_static_weight('None')
expect(page).to have_css(js_dropdown_weight, visible: false) expect(page).to have_css(js_dropdown_weight, visible: false)
expect_tokens([{ name: 'Weight', value: 'none' }]) expect_tokens([{ name: 'Weight', value: 'None' }])
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
end end
......
require 'rails_helper'
feature 'Issue Sidebar' do
include MobileHelpers
let(:group) { create(:group, :nested) }
let(:project) { create(:project, :public, namespace: group) }
let!(:user) { create(:user)}
let!(:label) { create(:label, project: project, title: 'bug') }
let(:issue) { create(:labeled_issue, project: project, labels: [label]) }
before do
sign_in(user)
end
context 'updating weight', :js do
before do
project.add_master(user)
visit_issue(project, issue)
end
it 'updates weight in sidebar to 1' do
page.within '.weight' do
click_link 'Edit'
find('input').send_keys 1, :enter
page.within '.value' do
expect(page).to have_content '1'
end
end
end
it 'updates weight in sidebar to no weight' do
page.within '.weight' do
click_link 'Edit'
find('input').send_keys 1, :enter
page.within '.value' do
expect(page).to have_content '1'
end
click_link 'remove weight'
page.within '.value' do
expect(page).to have_content 'None'
end
end
end
end
def visit_issue(project, issue)
visit project_issue_path(project, issue)
end
end
{
"type": "object",
"allOf": [
{
"$ref": "../../../../../spec/fixtures/api/schemas/list.json"
},
{
"required": ["user"],
"properties": {
"user": {
"type": [
"object",
"null"
],
"required": [
"id",
"name",
"username",
"state",
"avatar_url",
"web_url",
"path"
],
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"state": {
"type": "string"
},
"avatar_url": {
"type": "string"
},
"web_url": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
}
}
]
}
\ No newline at end of file
{
"type": "array",
"items": {
"$ref": "list.json"
}
}
\ No newline at end of file
require 'spec_helper'
describe BoardsHelper do
describe '#board_list_data' do
let(:results) { helper.board_list_data }
it 'contains an endpoint to get users list' do
project = create(:project)
board = create(:board, project: project)
assign(:board, board)
assign(:project, project)
expect(results).to include(list_assignees_path: "/-/boards/#{board.id}/users.json")
end
end
end
require 'rails_helper'
describe List do
context 'when it is an assignee type' do
let(:board) { create(:board) }
subject { described_class.new(list_type: :assignee, board: board) }
it { is_expected.to be_destroyable }
it { is_expected.to be_movable }
describe 'relationships' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:user) }
end
describe '#title' do
it 'returns the username as title' do
subject.user = create(:user, username: 'some_user')
expect(subject.title).to eq('@some_user')
end
end
end
end
...@@ -4,6 +4,29 @@ describe Issue do ...@@ -4,6 +4,29 @@ describe Issue do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
include ExternalAuthorizationServiceHelpers include ExternalAuthorizationServiceHelpers
describe 'validations' do
subject { build(:issue) }
describe 'weight' do
it 'is not valid when negative number' do
subject.weight = -1
expect(subject).not_to be_valid
expect(subject.errors[:weight]).not_to be_empty
end
it 'is valid when non-negative' do
subject.weight = 0
expect(subject).to be_valid
subject.weight = 1
expect(subject).to be_valid
end
end
end
describe '#allows_multiple_assignees?' do describe '#allows_multiple_assignees?' do
it 'does not allow multiple assignees without license' do it 'does not allow multiple assignees without license' do
stub_licensed_features(multiple_issue_assignees: false) stub_licensed_features(multiple_issue_assignees: false)
......
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.
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