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 {
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
scroll: true,
group: 'issues',
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
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) => {
const card = this.$refs.issue[e.oldIndex];
......@@ -180,10 +216,11 @@ export default {
:list="list"
v-if="list.type !== 'closed' && showIssueForm"/>
<ul
class="board-list"
class="board-list js-board-list"
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }">
<board-card
v-for="(issue, index) in issues"
......
......@@ -49,11 +49,12 @@ export default {
this.error = false;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees: [],
assignees,
project_id: this.selectedProject.id,
});
......
......@@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => {
filterable: true,
selectable: true,
multiSelect: true,
containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content',
clicked (options) {
const { e } = options;
const label = options.selectedObj;
......
......@@ -7,6 +7,7 @@ import Vue from 'vue';
import Flash from '~/flash';
import { __ } from '~/locale';
import '~/vue_shared/models/label';
import '~/vue_shared/models/assignee';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
......@@ -15,7 +16,6 @@ import './models/issue';
import './models/list';
import './models/milestone';
import './models/project';
import './models/assignee';
import './stores/boards_store';
import ModalStore from './stores/modal_store';
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 */
/* global ListIssue */
/* global ListLabel */
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
import queryData from '../utils/query_data';
const PER_PAGE = 20;
class List {
constructor (obj, defaultAvatar) {
constructor(obj, defaultAvatar) {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
......@@ -24,6 +26,9 @@ class List {
if (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) {
......@@ -34,14 +39,26 @@ class List {
}
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()}`;
}
save () {
return gl.boardService.createList(this.label.id)
save() {
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((data) => {
.then(data => {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
......@@ -50,25 +67,23 @@ class List {
});
}
destroy () {
destroy() {
const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this);
gl.issueBoards.BoardsStore.state.lists.splice(index, 1);
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
gl.boardService.destroyList(this.id)
.catch(() => {
// TODO: handle request error
});
gl.boardService.destroyList(this.id).catch(() => {
// TODO: handle request error
});
}
update () {
gl.boardService.updateList(this.id, this.position)
.catch(() => {
// TODO: handle request error
});
update() {
gl.boardService.updateList(this.id, this.position).catch(() => {
// TODO: handle request error
});
}
nextPage () {
nextPage() {
if (this.issuesSize > this.issues.length) {
if (this.issues.length / PER_PAGE >= 1) {
this.page += 1;
......@@ -78,7 +93,7 @@ class List {
}
}
getIssues (emptyIssues = true) {
getIssues(emptyIssues = true) {
const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page });
if (this.label && data.label_name) {
......@@ -89,9 +104,10 @@ class List {
this.loading = true;
}
return gl.boardService.getIssuesForList(this.id, data)
return gl.boardService
.getIssuesForList(this.id, data)
.then(res => res.data)
.then((data) => {
.then(data => {
this.loading = false;
this.issuesSize = data.size;
......@@ -103,18 +119,21 @@ class List {
});
}
newIssue (issue) {
newIssue(issue) {
this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
return gl.boardService
.newIssue(this.id, issue)
.then(res => res.data)
.then((data) => {
.then(data => {
issue.id = data.id;
issue.iid = data.iid;
issue.milestone = data.milestone;
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.path = data.real_path;
issue.referencePath = data.reference_path;
......@@ -126,13 +145,13 @@ class List {
});
}
createIssues (data) {
data.forEach((issueObj) => {
createIssues(data) {
data.forEach(issueObj => {
this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
});
}
addIssue (issue, listFrom, newIndex) {
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
......@@ -155,6 +174,13 @@ class List {
issue.addLabel(this.label);
}
if (this.assignee) {
if (listFrom && listFrom.type === 'assignee') {
issue.removeAssignee(listFrom.assignee);
}
issue.addAssignee(this.assignee);
}
if (listFrom) {
this.issuesSize += 1;
......@@ -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(newIndex, 0, issue);
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId)
.catch(() => {
// TODO: handle request error
});
gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
// TODO: handle request error
});
}
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(() => {
// TODO: handle request error
});
}
findIssue (id) {
findIssue(id) {
return this.issues.find(issue => issue.id === id);
}
removeIssue (removeIssue) {
this.issues = this.issues.filter((issue) => {
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) {
......
......@@ -60,11 +60,13 @@ export default class BoardService {
return axios.post(this.listsEndpointGenerate, {});
}
createList(labelId) {
createList(entityId, entityType) {
const list = {
[entityType]: entityId,
};
return axios.post(this.listsEndpoint, {
list: {
label_id: labelId,
},
list,
});
}
......
......@@ -121,8 +121,15 @@ gl.issueBoards.BoardsStore = {
const listLabels = issueLists.map(listIssue => listIssue.label);
if (!issueTo) {
// Add to new lists issues if it doesn't already exist
listTo.addIssue(issue, listFrom, newIndex);
// 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
listTo.addIssue(issue, listFrom, newIndex);
}
} else {
listTo.updateIssueLabel(issue, listFrom);
issueTo.removeLabel(listFrom.label);
......@@ -133,7 +140,11 @@ gl.issueBoards.BoardsStore = {
list.removeIssue(issue);
});
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);
}
},
......@@ -144,11 +155,12 @@ gl.issueBoards.BoardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
findList (key, val, type = 'label') {
return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true;
const filteredList = this.state.lists.filter((list) => {
const byType = type ? (list.type === type) || (list.type === 'assignee') : true;
return list[key] === val && byType;
})[0];
});
return filteredList[0];
},
updateFiltersUrl (replaceState = false) {
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';
import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user';
import DropdownWeight from './dropdown_weight';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
......@@ -92,12 +93,12 @@ export default class FilteredSearchDropdownManager {
},
weight: {
reference: null,
gl: DropdownNonUser,
gl: DropdownWeight,
element: this.container.querySelector('#js-dropdown-weight'),
},
};
supportedTokens.forEach((type) => {
supportedTokens.forEach(type => {
if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type];
}
......@@ -152,13 +153,16 @@ export default class FilteredSearchDropdownManager {
updateDropdownOffset(key) {
// 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 currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
// 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) {
offset = offsetMaxWidth;
}
......@@ -184,8 +188,7 @@ export default class FilteredSearchDropdownManager {
const glArguments = Object.assign({}, defaultArguments, extraArguments);
// Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference =
new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
}
if (firstLoad) {
......@@ -212,8 +215,8 @@ export default class FilteredSearchDropdownManager {
}
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key
&& this.mapping[match.key];
const shouldOpenFilterDropdown =
match && this.currentDropdown !== match.key && this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
......@@ -224,8 +227,10 @@ export default class FilteredSearchDropdownManager {
setDropdown() {
const query = DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } =
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
const { lastToken, searchToken } = this.tokenizer.processTokens(
query,
this.filteredSearchTokenKeys.getKeys(),
);
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
......
......@@ -602,7 +602,11 @@ GitLabDropdown = (function() {
var selector;
selector = '.dropdown-content';
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();
......
......@@ -22,4 +22,18 @@ document.addEventListener('DOMContentLoaded', () => {
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
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 {
color: $white-light;
}
}
input[type=color].form-control {
height: $input-height;
}
......@@ -215,6 +215,15 @@
}
}
}
&.weight {
.gl-field-error {
margin-top: $gl-padding-8;
margin-left: -6px;
display: flex;
align-items: center;
}
}
}
.block-first {
......
......@@ -127,12 +127,16 @@
color: $gl-danger;
}
.service-settings .form-control-label {
padding-top: 0;
.service-settings {
input[type="radio"],
input[type="checkbox"] {
margin-top: 10px;
}
}
.integration-settings-form {
.card.card-body {
.card.card-body,
.info-well {
padding: $gl-padding / 2;
box-shadow: none;
}
......
......@@ -16,3 +16,12 @@
.registry-placeholder {
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
class ListsController < Boards::ApplicationController
prepend ::EE::Boards::ListsController
include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
......@@ -56,8 +58,12 @@ module Boards
private
def list_creation_attrs
%i[label_id]
end
def list_params
params.require(:list).permit(:label_id)
params.require(:list).permit(list_creation_attrs)
end
def move_params
......@@ -65,11 +71,15 @@ module Boards
end
def serialize_as_json(resource)
resource.as_json(
resource.as_json(serialization_attrs)
end
def serialization_attrs
{
only: [:id, :list_type, :position],
methods: [:title],
label: true
)
}
end
end
end
......@@ -96,12 +96,7 @@ module IssuableCollections
elsif @group
@filter_params[:group_id] = @group.id
@filter_params[:include_subgroups] = true
else
# 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
@filter_params[:use_cte_for_search] = true
end
@filter_params.permit(finder_type.valid_params)
......
......@@ -41,7 +41,7 @@ module Projects
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled]
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy]
)
end
......
......@@ -3,17 +3,29 @@ class GroupMembersFinder
@group = group
end
def execute
def execute(include_descendants: false)
group_members = @group.members
wheres = []
return group_members unless @group.parent
return group_members unless @group.parent || include_descendants
parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id))
wheres << "members.id IN (#{group_members.select(:id).to_sql})"
wheres = ["members.id IN (#{group_members.select(:id).to_sql})"]
wheres << "members.id IN (#{parents_members.select(:id).to_sql})"
if @group.parent
parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id))
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 '))
end
......
......@@ -23,6 +23,7 @@
# created_before: datetime
# updated_after: datetime
# updated_before: datetime
# use_cte_for_search: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
......@@ -54,6 +55,7 @@ class IssuableFinder
sort
state
include_subgroups
use_cte_for_search
]
end
......@@ -74,19 +76,21 @@ class IssuableFinder
items = init_collection
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
items = by_project(items)
# This has to be last as we may use a CTE as an optimization fence by
# passing the use_cte_for_search param
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
sort(items)
end
def filter_items(items)
items = by_project(items)
items = by_scope(items)
items = by_created_at(items)
items = by_updated_at(items)
items = by_state(items)
items = by_group(items)
items = by_search(items)
items = by_assignee(items)
items = by_author(items)
items = by_non_archived(items)
......@@ -107,7 +111,6 @@ class IssuableFinder
#
def count_by_state
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)
counts = Hash.new(0)
......@@ -116,6 +119,11 @@ class IssuableFinder
# per issuable, so we have to count those in Ruby - which is bad, but still
# 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|
counts[Array(key).last.to_sym] += value / labels_count
end
......@@ -159,10 +167,7 @@ class IssuableFinder
finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
else
opts = { current_user: current_user }
opts[:project_ids_relation] = item_project_ids(items) if items
ProjectsFinder.new(opts).execute
ProjectsFinder.new(current_user: current_user).execute
end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
......@@ -329,8 +334,24 @@ class IssuableFinder
items
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)
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
def by_iids(items)
......
......@@ -157,8 +157,4 @@ class IssuesFinder < IssuableFinder
[]
end
end
def item_project_ids(items)
items&.reorder(nil)&.select(:project_id)
end
end
......@@ -7,12 +7,12 @@ class MembersFinder
@group = project.group
end
def execute
def execute(include_descendants: false)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
if group
group_members = GroupMembersFinder.new(group).execute
group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants)
group_members = group_members.non_invite
union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false)
......
......@@ -68,8 +68,4 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
end
class List < ActiveRecord::Base
prepend ::EE::List
belongs_to :board
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 :label, :position, presence: true, 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
scope :destroyable, -> { where(list_type: list_types[:label]) }
scope :movable, -> { where(list_type: list_types[:label]) }
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
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?
label?
......
class ProjectAutoDevops < ActiveRecord::Base
belongs_to :project
enum deploy_strategy: {
continuous: 0,
manual: 1
}
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
......@@ -22,6 +27,11 @@ class ProjectAutoDevops < ActiveRecord::Base
variables.append(key: 'AUTO_DEVOPS_DOMAIN',
value: domain.presence || instance_domain)
end
if manual?
variables.append(key: 'STAGING_ENABLED', value: 1)
variables.append(key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 1)
end
end
end
......
......@@ -5,13 +5,18 @@ module Boards
def execute
issues = IssuesFinder.new(current_user, filter_params).execute
issues = without_board_labels(issues) unless movable_list? || closed_list?
issues = with_list_label(issues) if movable_list?
issues = filter(issues)
issues.order_by_position_and_priority
end
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
@board ||= parent.boards.find(params[:board_id])
end
......@@ -22,18 +27,6 @@ module Boards
@list = board.lists.find(params[:id]) if params.key?(:id)
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
set_parent
set_state
......
module Boards
module Issues
class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(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)
end
......@@ -28,10 +30,10 @@ module Boards
end
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
def issue_params
def issue_params(issue)
attrs = {}
if move_between_lists?
......
module Boards
module Lists
class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
include Gitlab::Utils::StrongMemoize
def execute(board)
List.transaction do
label = available_labels_for(board).find(params[:label_id])
target = target(board)
position = next_position(board)
create_list(board, label, position)
create_list(board, type, target, position)
end
end
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)
options = { include_ancestor_groups: true }
......@@ -28,8 +42,8 @@ module Boards
max_position.nil? ? 0 : max_position.succ
end
def create_list(board, label, position)
board.lists.create(label: label, list_type: :label, position: position)
def create_list(board, type, target, position)
board.lists.create(type => target, list_type: type, position: position)
end
end
end
......
module Boards
module Lists
class ListService < Boards::BaseService
prepend ::EE::Boards::Lists::ListService
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
......
......@@ -17,7 +17,7 @@
.col-sm-10
= f.number_field :max_attachment_size, class: 'form-control'
= render 'repository_size_limit_setting', form: f
= render 'repository_size_limit_setting', form: f
.form-group.row
= f.label :session_expire_delay, 'Session duration (minutes)', class: 'col-form-label col-sm-2'
......@@ -44,8 +44,8 @@
= f.label :check_namespace_plan, 'Check feature availability on namespace plan', class: 'col-form-label col-sm-2'
.col-sm-10
.form-check
= f.label :check_namespace_plan do
= f.check_box :check_namespace_plan
= f.check_box :check_namespace_plan, class: 'form-check-input'
= 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
includes the feature or if the project is public.
......
......@@ -29,7 +29,7 @@
.form-group.row
.offset-sm-2.col-sm-10
.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
.option-title
Upload blacklist file
......
......@@ -2,7 +2,7 @@
= s_('PrometheusService|Auto configuration')
- if service.manual_configuration?
.card
.info-well
= s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
- else
.container-fluid
......
......@@ -5,5 +5,5 @@
= s_('PrometheusService|Manual configuration')
- unless @service.editable?
.card
.info-well
= s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
.row.prepend-top-default
.row
.col-lg-12
= form_for @project, url: project_settings_ci_cd_path(@project) do |f|
= form_errors(@project)
%fieldset.builds-feature
%fieldset.builds-feature.js-auto-devops-settings
.form-group
- message = auto_devops_warning_message(@project)
- ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe
- if message
%p.settings-message.text-center
%p.auto-devops-warning-message.settings-message.text-center
= message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
.form-check
= form.radio_button :enabled, 'true', class: 'form-check-input'
= form.label :enabled_true, class: 'form-check-label' do
%strong= s_('CICD|Enable Auto DevOps')
%br
= 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.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings'
= form.label :enabled_true, class: 'form-check-label' do
%strong= s_('CICD|Enable Auto DevOps')
.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 }
.form-check
= form.radio_button :enabled, 'false', class: 'form-check-input'
= 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 }
.card.auto-devops-card
.card-body
.form-check
= form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings'
= form.label :enabled_, class: 'form-check-label' do
%strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
.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 }
.form-check
= form.radio_button :enabled, '', class: 'form-check-input'
= form.label :enabled_, class: 'form-check-label' do
%strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" }
%br
= 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 }
.card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil }
.card-body.bg-light
= form.label :domain do
%strong= _('Domain')
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
.form-text.text-muted
= 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)
= 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'
= form.label :domain, class:"prepend-top-10" do
= _('Domain')
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
.form-text.text-muted
= s_('CICD|A domain is required to use Auto Review Apps and Auto Deploy Stages.')
- 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 }
= link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank'
.form-text.text-muted
= 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'
-# EE-specific start
.form-text.text-muted
= 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'
-# 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"
......@@ -9,11 +9,11 @@
- help = field[:help]
- disabled = disable_fields_service?(@service)
.form-group
.form-group.row
- 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
= form.label name, title, class: "col-form-label"
= form.label name, title, class: "col-form-label col-sm-2"
.col-sm-10
- if type == 'text'
= 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" }
.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)" }
......@@ -7,10 +7,18 @@
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"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\"",
":title" => '(list.label ? list.label.description : "")', data: { container: "body" } }
":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ 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\"",
":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" },
......
- show_close = local_assigns.fetch(:show_close, true)
- subject = @project || @group
.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-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
......
- 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_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search')
- show_boards_content = local_assigns.fetch(:show_boards_content, false)
- subject = @project || @group
.dropdown-page-one
= dropdown_title(title)
- if show_title
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
%p
= _('Create lists from labels. Issues with that label appear in that list.')
= content_title
= dropdown_filter(filter_placeholder)
= dropdown_content
- if current_board_parent && show_footer
......
......@@ -107,17 +107,16 @@
- if type == :issues || type == :boards || type == :boards_modal
#js-dropdown-weight.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ 'data-dropdown' => true }
%li.filter-dropdown-item{ 'data-value' => 'none' }
%li.filter-dropdown-item{ 'data-value' => 'None' }
%button.btn.btn-link
No Weight
%li.filter-dropdown-item{ 'data-value' => 'any' }
None
%li.filter-dropdown-item{ 'data-value' => 'Any' }
%button.btn.btn-link
Any Weight
Any
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ 'data-dropdown' => true }
- Issue.weight_filter_options.each do |weight|
%li.filter-dropdown-item{ 'data-value' => "#{weight}" }
%button.btn.btn-link= weight
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: '{{id}}' } }
%button.btn.btn-link {{title}}
%button.clear-search.hidden{ type: 'button' }
= icon('times')
......@@ -127,14 +126,7 @@
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list
.dropdown.prepend-left-10#js-add-list
%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
= render_if_exists 'shared/issuable/board_create_list_dropdown', board: board
- if @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
......
---
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
end
resources :issues, module: :boards, only: [:index, :update]
resources :users, module: :boards, only: [:index]
end
# 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 @@
#
# 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
enable_extension "plpgsql"
......@@ -1509,10 +1509,12 @@ ActiveRecord::Schema.define(version: 20180605213516) do
t.integer "position"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id"
end
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", ["user_id"], name: "index_lists_on_user_id", using: :btree
create_table "members", force: :cascade do |t|
t.integer "access_level", null: false
......@@ -1946,6 +1948,7 @@ ActiveRecord::Schema.define(version: 20180605213516) do
t.datetime "updated_at", null: false
t.boolean "enabled"
t.string "domain"
t.integer "deploy_strategy", default: 0, null: false
end
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
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", "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 "merge_request_diff_commits", "merge_request_diffs", on_delete: :cascade
add_foreign_key "merge_request_diff_files", "merge_request_diffs", on_delete: :cascade
......
......@@ -39,13 +39,15 @@ dast:
variables:
website: "https://example.com"
login_url: "https://example.com/sign-in"
username: "john.doe@example.com"
password: "john-doe-password"
allow_failure: true
script:
- mkdir /zap/wrk/
- /zap/zap-baseline.py -J gl-dast-report.json -t $website
--auth-url $login_url
--auth-username "john.doe@example.com"
--auth-password "john-doe-password" || true
--auth-username $username
--auth-password $password || true
- cp /zap/wrk/gl-dast-report.json .
artifacts:
paths: [gl-dast-report.json]
......
......@@ -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
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
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
[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.
#### Quick assignments
Create lists for each of your team members and quickly drag-and-drop issues onto each team member.
## Issue Board terminology
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 |
| -------------- | ------------- |
| **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. |
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.
![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
easy as dragging them around.
......
# Issue Weight **[STARTER]**
> [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.
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.
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
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 $ from 'jquery';
import { throttle } from 'underscore';
import BoardForm from './board_form.vue';
import AssigneesList from './assignees_list';
(() => {
window.gl = window.gl || {};
......@@ -34,6 +36,7 @@ import BoardForm from './board_form.vue';
open: false,
loading: true,
hasScrollFade: false,
hasAssigneesListMounted: false,
scrollFadeInitialized: false,
boards: [],
state: Store.state,
......@@ -91,9 +94,10 @@ import BoardForm from './board_form.vue';
}
if (this.open && !this.boards.length) {
gl.boardService.allBoards()
gl.boardService
.allBoards()
.then(res => res.data)
.then((json) => {
.then(json => {
this.loading = false;
this.boards = json;
})
......@@ -123,9 +127,32 @@ import BoardForm from './board_form.vue';
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() {
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 = {
param: '',
symbol: '',
icon: 'balance-scale',
tag: 'weight',
tag: 'number',
};
const weightConditions = [{
url: 'weight=No+Weight',
url: 'weight=None',
tokenKey: 'weight',
value: 'none',
value: 'None',
}, {
url: 'weight=Any+Weight',
url: 'weight=Any',
tokenKey: 'weight',
value: 'any',
value: 'Any',
}];
const alternativeTokenKeys = [{
......
<script>
import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import weightComponent from './weight.vue';
import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import weightComponent from './weight.vue';
export default {
components: {
weight: weightComponent,
},
props: {
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return mediatorObject.updateWeight && mediatorObject.store;
},
export default {
components: {
weight: weightComponent,
},
props: {
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return mediatorObject.updateWeight && mediatorObject.store;
},
},
},
created() {
eventHub.$on('updateWeight', this.onUpdateWeight);
},
created() {
eventHub.$on('updateWeight', this.onUpdateWeight);
},
beforeDestroy() {
eventHub.$off('updateWeight', this.onUpdateWeight);
},
beforeDestroy() {
eventHub.$off('updateWeight', this.onUpdateWeight);
},
methods: {
onUpdateWeight(newWeight) {
this.mediator.updateWeight(newWeight)
.catch(() => {
Flash('Error occurred while updating the issue weight');
});
},
methods: {
onUpdateWeight(newWeight) {
this.mediator.updateWeight(newWeight).catch(() => {
Flash('Error occurred while updating the issue weight');
});
},
};
},
};
</script>
<template>
......@@ -41,7 +40,6 @@
:fetching="mediator.store.isFetching.weight"
:loading="mediator.store.isLoading.weight"
:weight="mediator.store.weight"
:weight-options="mediator.store.weightOptions"
:weight-none-value="mediator.store.weightNoneValue"
:editable="mediator.store.editable"
/>
......
......@@ -4,3 +4,108 @@
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 BoardsHelper
extend ::Gitlab::Utils::Override
def parent
@group || @project
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
show_feature_promotion = (@project && show_promotions? &&
(!@project.feature_available?(:multiple_project_issue_boards) ||
......@@ -37,6 +45,7 @@ module EE
)
end
override :boards_link_text
def boards_link_text
if parent.multiple_issue_boards_available?
s_("IssueBoards|Boards")
......
......@@ -4,13 +4,15 @@ module EE
extend ::Gitlab::Utils::Override
prepended do
WEIGHT_RANGE = 1..9
WEIGHT_RANGE = 0..20
WEIGHT_ALL = 'Everything'.freeze
WEIGHT_ANY = 'Any Weight'.freeze
WEIGHT_NONE = 'No Weight'.freeze
WEIGHT_ANY = 'Any'.freeze
WEIGHT_NONE = 'None'.freeze
scope :order_weight_desc, -> { reorder ::Gitlab::Database.nulls_last_order('weight', 'DESC') }
scope :order_weight_asc, -> { reorder ::Gitlab::Database.nulls_last_order('weight') }
validates :weight, allow_nil: true, numericality: { greater_than_or_equal_to: 0 }
end
# 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
EEP_FEATURES = EES_FEATURES + %i[
admin_audit_log
auditor_user
board_assignee_lists
cross_project_pipelines
email_additional_text
db_load_balancing
......
......@@ -6,13 +6,14 @@ module EE
override :issue_params
def issue_params
assignee_ids = Array(list.user_id || board.assignee&.id)
{
label_ids: [list.label_id, *board.label_ids],
weight: board.weight,
milestone_id: board.milestone_id,
# This can be removed when boards have multiple assignee support.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/3786
assignee_ids: Array(board.assignee&.id)
assignee_ids: assignee_ids
}
end
end
......
......@@ -2,6 +2,18 @@ module EE
module Boards
module Issues
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
if has_valid_milestone?
super.where("issues.milestone_id = ?", board.milestone_id)
......@@ -12,6 +24,25 @@ module EE
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
# like Milestone::Upcoming, Milestone::Started etc
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
explanation do |weight|
"Sets weight to #{weight}." if weight
end
params ::Issue::WEIGHT_RANGE.to_s.squeeze('.').tr('.', '-')
params "0, 1, 2, …"
condition do
issuable.supports_weight? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
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
command :weight do |weight|
@updates[:weight] = weight if weight
......
......@@ -167,8 +167,9 @@ module Geo
attrs["resync_#{type}"] = true
attrs["last_#{type}_sync_failure"] = "#{message}: #{error.message}"
attrs["#{type}_retry_count"] = retry_count + 1
registry.update!(attrs)
repository.clean_stale_repository_files
end
def type
......
......@@ -3,22 +3,22 @@
%fieldset.system_header_footer
%legend
= _('System header and footer:')
.form-group
= form.label :header_message, _('Header message'), class: 'col-form-label'
.form-group.row
= form.label :header_message, _('Header message'), class: 'col-sm-2 col-form-label'
.col-sm-10
= form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
.form-group
= form.label :footer_message, _('Footer message'), class: 'col-form-label'
.form-group.row
= form.label :footer_message, _('Footer message'), class: 'col-sm-2 col-form-label'
.col-sm-10
= 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
= link_to _('Customize colors'), '#', class: 'js-toggle-colors-link'
.form-group.js-toggle-colors-container.hide
= form.label :message_background_color, _('Background Color'), class: 'col-form-label'
.form-group.row.js-toggle-colors-container.hide
= form.label :message_background_color, _('Background Color'), class: 'col-sm-2 col-form-label'
.col-sm-10
= form.color_field :message_background_color, class: "form-control"
.form-group.js-toggle-colors-container.hide
= form.label :message_font_color, _('Font Color'), class: 'col-form-label'
.form-group.row.js-toggle-colors-container.hide
= form.label :message_font_color, _('Font Color'), class: 'col-sm-2 col-form-label'
.col-sm-10
= form.color_field :message_font_color, class: "form-control"
......@@ -2,7 +2,7 @@
- form = local_assigns.fetch(:form)
.form-group
.form-group.row
= form.label :repository_size_limit, class: 'col-form-label col-sm-2' do
Size limit per repository (MB)
.col-sm-10
......
.form-group
.form-group.row
= form.label :shared_runners_minutes, 'Pipeline minutes quota', class: 'col-form-label col-sm-2'
.col-sm-10
= form.number_field :shared_runners_minutes, class: 'form-control'
......
- return unless License.feature_available?(:project_creation_level)
- form = local_assigns.fetch(:form)
- 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'
.col-sm-10
= 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 @@
- type = local_assigns.fetch(:type)
- label_class = (type == :project) ? 'label-light' : 'col-form-label'
.form-group
.form-group.row
= form.label :repository_size_limit, class: label_class do
Repository size limit (MB)
- 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 @@
- form = local_assigns.fetch(:form)
.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
.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
- if issuable.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
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
end
......@@ -62,7 +62,7 @@ describe Boards::ListsController do
it 'returns the created list' do
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
......
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
end
end
it 'creates board filtering by "Any weight"' do
create_board_weight('Any Weight')
it 'creates board filtering by "Any" weight' do
create_board_weight('Any')
expect(page).to have_selector('.board-card', count: 4)
end
......@@ -356,7 +356,7 @@ describe 'Scoped issue boards', :js do
end
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)
end
......
......@@ -186,7 +186,7 @@ describe 'Issue Boards', :js do
page.within '.weight' do
click_link 'Edit'
click_link '1'
find('.block.weight input').send_keys 1, :enter
page.within '.value' do
expect(page).to have_content '1'
......@@ -212,8 +212,7 @@ describe 'Issue Boards', :js do
wait_for_requests
page.within '.weight' do
click_link 'Edit'
click_link 'No Weight'
click_link 'remove weight'
page.within '.value' do
expect(page).to have_content 'None'
......
......@@ -17,7 +17,7 @@ describe 'Dropdown weight', :js do
end
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
def click_static_weight(text)
......@@ -49,7 +49,7 @@ describe 'Dropdown weight', :js do
it 'should load all the weights when opened' do
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
......@@ -83,10 +83,10 @@ describe 'Dropdown weight', :js do
end
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_tokens([{ name: 'Weight', value: 'none' }])
expect_tokens([{ name: 'Weight', value: 'None' }])
expect_filtered_search_input_empty
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
using RSpec::Parameterized::TableSyntax
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
it 'does not allow multiple assignees without license' do
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