Commit fb897f53 authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge remote-tracking branch 'upstream/master' into 32815--Add-Custom-CI-Config-Path

* upstream/master: (39 commits)
  Fix rubocop offenses
  Make entrypoint and command keys to be array of strings
  Add issuable-list class to shared mr/issue lists to fix new responsive layout
  New navigation breadcrumbs
  Restore timeago translations in renderTimeago.
  Automatically hide sidebar on smaller screens
  Fix typo in IssuesFinder comment
  Remove placeholder note when award emoji slash command is applied
  Make setSidebarHeight more efficient with SidebarHeightManager.
  Update CHANGELOG.md for 9.3.3
  Resolve "More actions dropdown hidden by end of diff"
  Use Gitaly 0.14.0
  Improve support for external issue references
  Make issuables_count_for_state public
  Only verifies top position after the request has finished to account for errors
  Clarify counter caching for users without project access
  Make finders responsible for counter cache keys
  Add changelog entry for issue / MR tab counting optimisations
  Don't count any confidential issues for non-project-members
  Cache total issue / MR counts for project by user type
  ...
parents cf996b44 b071317c
......@@ -11,6 +11,7 @@
"gon": false,
"localStorage": false
},
"parser": "babel-eslint",
"plugins": [
"filenames",
"import",
......
......@@ -474,9 +474,10 @@ codeclimate:
services:
- docker:dind
script:
- docker pull stedolan/jq
- docker pull codeclimate/codeclimate
- docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > codeclimate.json
- sed -i.bak 's/\({"body":"\)[^"]*\("}\)/\1\2/g' codeclimate.json
- docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > raw_codeclimate.json
- cat raw_codeclimate.json | docker run -i stedolan/jq -c 'map({check_name,fingerprint,location})' > codeclimate.json
artifacts:
paths: [codeclimate.json]
......
......@@ -2,6 +2,15 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 9.3.3 (2017-06-30)
- Fix head pipeline stored in merge request for external pipelines. !12478
- Bring back branches badge to main project page. !12548
- Fix diff of requirements.txt file by not matching newlines as part of package names.
- Perform housekeeping only when an import of a fresh project is completed.
- Fixed issue boards closed list not showing all closed issues.
- Fixed multi-line markdown tooltip buttons in issue edit form.
## 9.3.2 (2017-06-27)
- API: Fix optional arugments for POST :id/variables. !12474
......
......@@ -2,7 +2,6 @@
/* global Flash */
import Cookies from 'js-cookie';
import * as Emoji from './emoji';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
......@@ -24,27 +23,9 @@ const categoryLabelMap = {
flags: 'Flags',
};
function renderCategory(name, emojiList, opts = {}) {
return `
<h5 class="emoji-menu-title">
${name}
</h5>
<ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
${Emoji.glEmojiTag(emojiName, {
sprite: true,
})}
</button>
</li>
`).join('\n')}
</ul>
`;
}
export default class AwardsHandler {
constructor() {
class AwardsHandler {
constructor(emoji) {
this.emoji = emoji;
this.eventListeners = [];
// If the user shows intent let's pre-build the menu
this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
......@@ -78,10 +59,10 @@ export default class AwardsHandler {
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
const $spriteIconElement = $target.find('.icon');
const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
$target.closest('.js-awards-block').addClass('current');
this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji);
this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
});
}
......@@ -139,16 +120,16 @@ export default class AwardsHandler {
this.isCreatingEmojiMenu = true;
// Render the first category
const categoryMap = Emoji.getEmojiCategoryMap();
const categoryMap = this.emoji.getEmojiCategoryMap();
const categoryNameKey = Object.keys(categoryMap)[0];
const emojisInCategory = categoryMap[categoryNameKey];
const firstCategory = renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
// Render the frequently used
const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
let frequentlyUsedCatgegory = '';
if (frequentlyUsedEmojis.length > 0) {
frequentlyUsedCatgegory = renderCategory('Frequently used', frequentlyUsedEmojis, {
frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, {
menuListClass: 'frequent-emojis',
});
}
......@@ -179,7 +160,7 @@ export default class AwardsHandler {
}
this.isAddingRemainingEmojiMenuCategories = true;
const categoryMap = Emoji.getEmojiCategoryMap();
const categoryMap = this.emoji.getEmojiCategoryMap();
// Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive
......@@ -191,7 +172,7 @@ export default class AwardsHandler {
promiseChain.then(() =>
new Promise((resolve) => {
const emojisInCategory = categoryMap[categoryNameKey];
const categoryMarkup = renderCategory(
const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
......@@ -216,6 +197,25 @@ export default class AwardsHandler {
});
}
renderCategory(name, emojiList, opts = {}) {
return `
<h5 class="emoji-menu-title">
${name}
</h5>
<ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
${this.emoji.glEmojiTag(emojiName, {
sprite: true,
})}
</button>
</li>
`).join('\n')}
</ul>
`;
}
positionMenu($menu, $addBtn) {
const position = $addBtn.data('position');
// The menu could potentially be off-screen or in a hidden overflow element
......@@ -234,7 +234,7 @@ export default class AwardsHandler {
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const normalizedEmoji = Emoji.normalizeEmojiName(emoji);
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
......@@ -249,7 +249,7 @@ export default class AwardsHandler {
this.checkMutuality(votesBlock, emoji);
}
this.addEmojiToFrequentlyUsedList(emoji);
const normalizedEmoji = Emoji.normalizeEmojiName(emoji);
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
if ($emojiButton.length > 0) {
if (this.isActive($emojiButton)) {
......@@ -374,7 +374,7 @@ export default class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
${Emoji.glEmojiTag(emojiName)}
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
`;
......@@ -440,7 +440,7 @@ export default class AwardsHandler {
}
addEmojiToFrequentlyUsedList(emoji) {
if (Emoji.isEmojiNameValid(emoji)) {
if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
......@@ -450,7 +450,7 @@ export default class AwardsHandler {
return this.frequentlyUsedEmojis || (() => {
const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
inputName => Emoji.isEmojiNameValid(inputName),
inputName => this.emoji.isEmojiNameValid(inputName),
);
return this.frequentlyUsedEmojis;
......@@ -493,7 +493,7 @@ export default class AwardsHandler {
}
findMatchingEmojiElements(query) {
const emojiMatches = Emoji.filterEmojiNamesByAlias(query);
const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements
.filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
......@@ -507,3 +507,12 @@ export default class AwardsHandler {
$('.emoji-menu').remove();
}
}
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
.then(Emoji => new AwardsHandler(Emoji));
}
return awardsHandlerPromise;
}
import installCustomElements from 'document-register-element';
import { emojiImageTag, emojiFallbackImageSrc } from '../emoji';
import isEmojiUnicodeSupported from '../emoji/support';
installCustomElements(window);
......@@ -32,12 +31,20 @@ export default function installGlEmojiElement() {
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
} else if (hasImageFallback) {
} else {
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(({ emojiImageTag, emojiFallbackImageSrc }) => {
if (hasImageFallback) {
this.innerHTML = emojiImageTag(name, fallbackSrc);
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = emojiImageTag(name, src);
}
})
.catch(() => {
// do nothing
});
}
}
};
......
......@@ -85,9 +85,8 @@ window.Build = (function () {
if (!this.hasBeenScrolled) {
this.scrollToBottom();
}
});
this.verifyTopPosition();
})
.then(() => this.verifyTopPosition());
}
Build.prototype.canScroll = function () {
......@@ -176,7 +175,7 @@ window.Build = (function () {
}
if ($flashError.length) {
topPostion += $flashError.outerHeight();
topPostion += $flashError.outerHeight() + prependTopDefault;
}
this.$buildTrace.css({
......@@ -234,7 +233,8 @@ window.Build = (function () {
if (!this.hasBeenScrolled) {
this.scrollToBottom();
}
});
})
.then(() => this.verifyTopPosition());
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
......
/* eslint-disable class-methods-use-this */
import './lib/utils/url_utility';
import FilesCommentButton from './files_comment_button';
const UNFOLD_COUNT = 20;
let isBound = false;
......@@ -8,8 +9,10 @@ let isBound = false;
class Diff {
constructor() {
const $diffFile = $('.files .diff-file');
$diffFile.singleFileDiff();
$diffFile.filesCommentButton();
FilesCommentButton.init($diffFile);
$diffFile.each((index, file) => new gl.ImageFile(file));
......
......@@ -139,9 +139,9 @@ const DiffNoteAvatars = Vue.extend({
const notesCount = this.notesCount;
$(this.$el).closest('.js-avatar-container')
.toggleClass('js-no-comment-btn', notesCount > 0)
.toggleClass('no-comment-btn', notesCount > 0)
.nextUntil('.js-avatar-container')
.toggleClass('js-no-comment-btn', notesCount > 0);
.toggleClass('no-comment-btn', notesCount > 0);
},
toggleDiscussionsToggleState() {
const $notesHolders = $(this.$el).closest('.code').find('.notes_holder');
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len, one-var, one-var-declaration-per-line, quotes, prefer-template, newline-per-chained-call, comma-dangle, new-cap, no-else-return, consistent-return */
/* global FilesCommentButton */
/* global notes */
let $commentButtonTemplate;
window.FilesCommentButton = (function() {
var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS;
COMMENT_BUTTON_CLASS = '.add-diff-note';
LINE_HOLDER_CLASS = '.line_holder';
LINE_NUMBER_CLASS = 'diff-line-num';
LINE_CONTENT_CLASS = 'line_content';
UNFOLDABLE_LINE_CLASS = 'js-unfold';
EMPTY_CELL_CLASS = 'empty-cell';
OLD_LINE_CLASS = 'old_line';
LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content";
TEXT_FILE_SELECTOR = '.text-file';
function FilesCommentButton(filesContainerElement) {
this.render = this.render.bind(this);
this.hideButton = this.hideButton.bind(this);
this.isParallelView = notes.isParallelView();
filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render)
.on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton);
/* Developer beware! Do not add logic to showButton or hideButton
* that will force a reflow. Doing so will create a signficant performance
* bottleneck for pages with large diffs. For a comprehensive list of what
* causes reflows, visit https://gist.github.com/paulirish/5d52fb081b3570c81e3a
*/
const LINE_NUMBER_CLASS = 'diff-line-num';
const UNFOLDABLE_LINE_CLASS = 'js-unfold';
const NO_COMMENT_CLASS = 'no-comment-btn';
const EMPTY_CELL_CLASS = 'empty-cell';
const OLD_LINE_CLASS = 'old_line';
const LINE_COLUMN_CLASSES = `.${LINE_NUMBER_CLASS}, .line_content`;
const DIFF_CONTAINER_SELECTOR = '.files';
const DIFF_EXPANDED_CLASS = 'diff-expanded';
export default {
init($diffFile) {
/* Caching is used only when the following members are *true*. This is because there are likely to be
* differently configured versions of diffs in the same session. However if these values are true, they
* will be true in all cases */
if (!this.userCanCreateNote) {
// data-can-create-note is an empty string when true, otherwise undefined
this.userCanCreateNote = $diffFile.closest(DIFF_CONTAINER_SELECTOR).data('can-create-note') === '';
}
FilesCommentButton.prototype.render = function(e) {
var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button;
$currentTarget = $(e.currentTarget);
if ($currentTarget.hasClass('js-no-comment-btn')) return;
lineContentElement = this.getLineContent($currentTarget);
buttonParentElement = this.getButtonParent($currentTarget);
if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return;
$button = $(COMMENT_BUTTON_CLASS, buttonParentElement);
buttonParentElement.addClass('is-over')
.nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over');
if ($button.length) {
return;
if (typeof notes !== 'undefined' && !this.isParallelView) {
this.isParallelView = notes.isParallelView && notes.isParallelView();
}
textFileElement = this.getTextFileElement($currentTarget);
buttonParentElement.append(this.buildButton({
discussionID: lineContentElement.attr('data-discussion-id'),
lineType: lineContentElement.attr('data-line-type'),
noteableType: textFileElement.attr('data-noteable-type'),
noteableID: textFileElement.attr('data-noteable-id'),
commitID: textFileElement.attr('data-commit-id'),
noteType: lineContentElement.attr('data-note-type'),
// LegacyDiffNote
lineCode: lineContentElement.attr('data-line-code'),
// DiffNote
position: lineContentElement.attr('data-position')
}));
};
FilesCommentButton.prototype.hideButton = function(e) {
var $currentTarget = $(e.currentTarget);
var buttonParentElement = this.getButtonParent($currentTarget);
buttonParentElement.removeClass('is-over')
.nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over');
};
FilesCommentButton.prototype.buildButton = function(buttonAttributes) {
return $commentButtonTemplate.clone().attr({
'data-discussion-id': buttonAttributes.discussionID,
'data-line-type': buttonAttributes.lineType,
'data-noteable-type': buttonAttributes.noteableType,
'data-noteable-id': buttonAttributes.noteableID,
'data-commit-id': buttonAttributes.commitID,
'data-note-type': buttonAttributes.noteType,
// LegacyDiffNote
'data-line-code': buttonAttributes.lineCode,
// DiffNote
'data-position': buttonAttributes.position
});
};
FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) {
return hoveredElement.closest(TEXT_FILE_SELECTOR);
};
FilesCommentButton.prototype.getLineContent = function(hoveredElement) {
if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) {
return hoveredElement;
}
if (!this.isParallelView) {
return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS);
} else {
return $(hoveredElement).next("." + LINE_CONTENT_CLASS);
if (this.userCanCreateNote) {
$diffFile.on('mouseover', LINE_COLUMN_CLASSES, e => this.showButton(this.isParallelView, e))
.on('mouseleave', LINE_COLUMN_CLASSES, e => this.hideButton(this.isParallelView, e));
}
};
},
FilesCommentButton.prototype.getButtonParent = function(hoveredElement) {
if (!this.isParallelView) {
if (hoveredElement.hasClass(OLD_LINE_CLASS)) {
return hoveredElement;
}
return hoveredElement.parent().find("." + OLD_LINE_CLASS);
} else {
if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) {
return hoveredElement;
}
return $(hoveredElement).prev("." + LINE_NUMBER_CLASS);
}
};
showButton(isParallelView, e) {
const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) {
return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS);
};
if (!this.validateButtonParent(buttonParentElement)) return;
FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== '';
};
buttonParentElement.classList.add('is-over');
buttonParentElement.nextElementSibling.classList.add('is-over');
},
return FilesCommentButton;
})();
hideButton(isParallelView, e) {
const buttonParentElement = this.getButtonParent(e.currentTarget, isParallelView);
$.fn.filesCommentButton = function() {
$commentButtonTemplate = $('<button name="button" type="submit" class="add-diff-note js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>');
buttonParentElement.classList.remove('is-over');
buttonParentElement.nextElementSibling.classList.remove('is-over');
},
if (!(this && (this.parent().data('can-create-note') != null))) {
return;
getButtonParent(hoveredElement, isParallelView) {
if (isParallelView) {
if (!hoveredElement.classList.contains(LINE_NUMBER_CLASS)) {
return hoveredElement.previousElementSibling;
}
return this.each(function() {
if (!$.data(this, 'filesCommentButton')) {
return $.data(this, 'filesCommentButton', new FilesCommentButton($(this)));
} else if (!hoveredElement.classList.contains(OLD_LINE_CLASS)) {
return hoveredElement.parentNode.querySelector(`.${OLD_LINE_CLASS}`);
}
});
return hoveredElement;
},
validateButtonParent(buttonParentElement) {
return !buttonParentElement.classList.contains(EMPTY_CELL_CLASS) &&
!buttonParentElement.classList.contains(UNFOLDABLE_LINE_CLASS) &&
!buttonParentElement.classList.contains(NO_COMMENT_CLASS) &&
!buttonParentElement.parentNode.classList.contains(DIFF_EXPANDED_CLASS);
},
};
import { validEmojiNames, glEmojiTag } from './emoji';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
......@@ -373,7 +372,12 @@ class GfmAutoComplete {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
import(/* webpackChunkName: 'emoji' */ './emoji')
.then(({ validEmojiNames, glEmojiTag }) => {
this.loadData($input, at, validEmojiNames);
GfmAutoComplete.glEmojiTag = glEmojiTag;
})
.catch(() => { this.isLoadingData[at] = false; });
} else {
AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
.then((data) => {
......@@ -428,12 +432,14 @@ GfmAutoComplete.atTypeMap = {
};
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
templateFunction(name) {
return `<li>
${name} ${glEmojiTag(name)}
</li>
`;
// glEmojiTag helper is loaded on-demand in fetchData()
if (GfmAutoComplete.glEmojiTag) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
}
return `<li>${name}</li>`;
},
};
// Team Members
......
import Cookies from 'js-cookie';
import _ from 'underscore';
export default class GroupName {
constructor() {
this.titleContainer = document.querySelector('.title-container');
this.title = document.querySelector('.title');
this.titleContainer = document.querySelector('.js-title-container');
this.title = this.titleContainer.querySelector('.title');
this.titleWidth = this.title.offsetWidth;
this.groupTitle = document.querySelector('.group-title');
this.groups = document.querySelectorAll('.group-path');
this.groupTitle = this.titleContainer.querySelector('.group-title');
this.groups = this.titleContainer.querySelectorAll('.group-path');
this.toggle = null;
this.isHidden = false;
this.init();
......@@ -33,11 +33,20 @@ export default class GroupName {
createToggle() {
this.toggle = document.createElement('button');
this.toggle.setAttribute('type', 'button');
this.toggle.className = 'text-expander group-name-toggle';
this.toggle.setAttribute('aria-label', 'Toggle full path');
if (Cookies.get('new_nav') === 'true') {
this.toggle.innerHTML = '<i class="fa fa-ellipsis-h" aria-hidden="true"></i>';
} else {
this.toggle.innerHTML = '...';
}
this.toggle.addEventListener('click', this.toggleGroups.bind(this));
if (Cookies.get('new_nav') === 'true') {
this.title.insertBefore(this.toggle, this.groupTitle);
} else {
this.titleContainer.insertBefore(this.toggle, this.title);
}
this.toggleGroups();
}
......
......@@ -5,6 +5,7 @@
/* global SubscriptionSelect */
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import SidebarHeightManager from './sidebar_height_manager';
const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content';
......@@ -56,18 +57,6 @@ export default class IssuableBulkUpdateSidebar {
return navbarHeight + layoutNavHeight + subNavScroll;
}
initSidebar() {
if (!this.navHeight) {
this.navHeight = this.getNavHeight();
}
if (!this.sidebarInitialized) {
$(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this));
$(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this));
this.sidebarInitialized = true;
}
}
setupBulkUpdateActions() {
IssuableBulkUpdateActions.setOriginalDropdownData();
}
......@@ -97,7 +86,7 @@ export default class IssuableBulkUpdateSidebar {
this.toggleCheckboxDisplay(enable);
if (enable) {
this.initSidebar();
SidebarHeightManager.init();
}
}
......@@ -143,17 +132,6 @@ export default class IssuableBulkUpdateSidebar {
this.$bulkEditSubmitBtn.enable();
}
}
// loosely based on method of the same name in right_sidebar.js
setSidebarHeight() {
const currentScrollDepth = window.pageYOffset || 0;
const diff = this.navHeight - currentScrollDepth;
if (diff > 0) {
this.$sidebar.outerHeight(window.innerHeight - diff);
} else {
this.$sidebar.outerHeight('100%');
}
}
static getCheckedIssueIds() {
const $checkedIssues = $('.selected_issue:checked');
......
......@@ -112,29 +112,11 @@ window.dateFormat = dateFormat;
return timefor;
};
w.gl.utils.cachedTimeagoElements = [];
w.gl.utils.renderTimeago = function($els) {
if (!$els && !w.gl.utils.cachedTimeagoElements.length) {
w.gl.utils.cachedTimeagoElements = [].slice.call(document.querySelectorAll('.js-timeago-render'));
} else if ($els) {
w.gl.utils.cachedTimeagoElements = w.gl.utils.cachedTimeagoElements.concat($els.toArray());
}
w.gl.utils.cachedTimeagoElements.forEach(gl.utils.updateTimeagoText);
};
w.gl.utils.updateTimeagoText = function(el) {
const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), lang);
if (el.textContent !== formattedDate) {
el.textContent = formattedDate;
}
};
w.gl.utils.initTimeagoTimeout = function() {
gl.utils.renderTimeago();
const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
gl.utils.timeagoTimeout = setTimeout(gl.utils.initTimeagoTimeout, 1000);
// timeago.js sets timeouts internally for each timeago value to be updated in real time
gl.utils.getTimeago().render(timeagoEls, lang);
};
w.gl.utils.getDayDifference = function(a, b) {
......
......@@ -70,7 +70,7 @@ import './ajax_loading_spinner';
import './api';
import './aside';
import './autosave';
import AwardsHandler from './awards_handler';
import loadAwardsHandler from './awards_handler';
import './breakpoints';
import './broadcast_message';
import './build';
......@@ -355,10 +355,10 @@ $(function () {
$window.off('resize.app').on('resize.app', function () {
return fitSidebarForSize();
});
gl.awardsHandler = new AwardsHandler();
loadAwardsHandler();
new Aside();
gl.utils.initTimeagoTimeout();
gl.utils.renderTimeago();
$(document).trigger('init.scrolling-tabs');
});
......@@ -144,7 +144,9 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
this.resetViewContainer();
this.mountPipelinesView();
} else {
if (Breakpoints.get().getBreakpointSize() !== 'xs') {
this.expandView();
}
this.resetViewContainer();
this.destroyPipelinesView();
}
......
......@@ -18,6 +18,7 @@ import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
import AjaxCache from '~/lib/utils/ajax_cache';
import CommentTypeToggle from './comment_type_toggle';
import loadAwardsHandler from './awards_handler';
import './autosave';
import './dropzone_input';
import './task_list';
......@@ -291,8 +292,13 @@ export default class Notes {
if ('emoji_award' in noteEntity.commands_changes) {
votesBlock = $('.js-awards-block').eq(0);
gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
return gl.awardsHandler.scrollToAwards();
loadAwardsHandler().then((awardsHandler) => {
awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award);
awardsHandler.scrollToAwards();
}).catch(() => {
// ignore
});
}
}
}
......@@ -337,6 +343,10 @@ export default class Notes {
if (!noteEntity.valid) {
if (noteEntity.errors.commands_only) {
if (noteEntity.commands_changes &&
Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove();
}
this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
}
......@@ -829,6 +839,8 @@ export default class Notes {
*/
setupDiscussionNoteForm(dataHolder, form) {
// setup note target
const diffFileData = dataHolder.closest('.text-file');
var discussionID = dataHolder.data('discussionId');
if (discussionID) {
......@@ -839,9 +851,10 @@ export default class Notes {
form.attr('data-line-code', dataHolder.data('lineCode'));
form.find('#line_type').val(dataHolder.data('lineType'));
form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
form.find('#note_commit_id').val(dataHolder.data('commitId'));
form.find('#note_noteable_type').val(diffFileData.data('noteableType'));
form.find('#note_noteable_id').val(diffFileData.data('noteableId'));
form.find('#note_commit_id').val(diffFileData.data('commitId'));
form.find('#note_type').val(dataHolder.data('noteType'));
// LegacyDiffNote
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, object-shorthand, comma-dangle, no-else-return, no-param-reassign, max-len */
import Cookies from 'js-cookie';
import SidebarHeightManager from './sidebar_height_manager';
(function() {
this.Sidebar = (function() {
......@@ -8,12 +9,6 @@ import Cookies from 'js-cookie';
this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
this.$sidebarInner = this.sidebar.find('.issuable-sidebar');
this.$navGitlab = $('.navbar-gitlab');
this.$layoutNav = $('.layout-nav');
this.$subScroll = $('.sub-nav-scroll');
this.$rightSidebar = $('.js-right-sidebar');
this.removeListeners();
this.addEventListeners();
}
......@@ -27,16 +22,14 @@ import Cookies from 'js-cookie';
};
Sidebar.prototype.addEventListeners = function() {
SidebarHeightManager.init();
const $document = $(document);
const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight.bind(this), 20);
const slowerThrottledSetSidebarHeight = _.throttle(this.setSidebarHeight.bind(this), 200);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
$(window).on('resize', () => throttledSetSidebarHeight());
$document.on('scroll', () => slowerThrottledSetSidebarHeight());
$document.on('click', '.js-sidebar-toggle', function(e, triggered) {
var $allGutterToggleIcons, $this, $thisIcon;
e.preventDefault();
......@@ -214,18 +207,6 @@ import Cookies from 'js-cookie';
}
};
Sidebar.prototype.setSidebarHeight = function() {
const $navHeight = this.$navGitlab.outerHeight() + this.$layoutNav.outerHeight() + (this.$subScroll ? this.$subScroll.outerHeight() : 0);
const diff = $navHeight - $(window).scrollTop();
if (diff > 0) {
this.$rightSidebar.outerHeight($(window).height() - diff);
this.$sidebarInner.height('100%');
} else {
this.$rightSidebar.outerHeight('100%');
this.$sidebarInner.height('');
}
};
Sidebar.prototype.isOpen = function() {
return this.sidebar.is('.right-sidebar-expanded');
};
......
export default {
init() {
if (!this.initialized) {
this.$window = $(window);
this.$rightSidebar = $('.js-right-sidebar');
this.$navHeight = $('.navbar-gitlab').outerHeight() +
$('.layout-nav').outerHeight() +
$('.sub-nav-scroll').outerHeight();
const throttledSetSidebarHeight = _.throttle(() => this.setSidebarHeight(), 20);
const debouncedSetSidebarHeight = _.debounce(() => this.setSidebarHeight(), 200);
this.$window.on('scroll', throttledSetSidebarHeight);
this.$window.on('resize', debouncedSetSidebarHeight);
this.initialized = true;
}
},
setSidebarHeight() {
const currentScrollDepth = window.pageYOffset || 0;
const diff = this.$navHeight - currentScrollDepth;
if (diff > 0) {
const newSidebarHeight = window.innerHeight - diff;
this.$rightSidebar.outerHeight(newSidebarHeight);
this.sidebarHeightIsCustom = true;
} else if (this.sidebarHeightIsCustom) {
this.$rightSidebar.outerHeight('100%');
this.sidebarHeightIsCustom = false;
}
},
};
/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */
import FilesCommentButton from './files_comment_button';
(function() {
window.SingleFileDiff = (function() {
var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
......@@ -78,6 +80,8 @@
gl.diffNotesCompileComponents();
}
FilesCommentButton.init($(_this.file));
if (cb) cb();
};
})(this));
......
/**
* This is the first script loaded by webpack's runtime. It is used to manually configure
* config.output.publicPath to account for relative_url_root or CDN settings which cannot be
* baked-in to our webpack bundles.
*/
if (gon && gon.webpack_public_path) {
__webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line
}
......@@ -264,3 +264,127 @@ header.navbar-gitlab-new {
}
}
}
.breadcrumbs {
display: flex;
min-height: 60px;
padding-top: $gl-padding-top;
padding-bottom: $gl-padding-top;
color: $gl-text-color;
border-bottom: 1px solid $border-color;
.dropdown-toggle-caret {
position: relative;
top: -1px;
padding: 0 5px;
color: rgba($black, .65);
font-size: 10px;
line-height: 1;
background: none;
border: 0;
&:focus {
outline: 0;
}
}
}
.breadcrumbs-container {
display: flex;
width: 100%;
position: relative;
.dropdown-menu-projects {
margin-top: -$gl-padding;
margin-left: $gl-padding;
}
}
.breadcrumbs-links {
flex: 1;
align-self: center;
color: $black-transparent;
a {
color: rgba($black, .65);
&:not(:first-child),
&.group-path {
margin-left: 4px;
}
&:not(:last-of-type),
&.group-path {
margin-right: 3px;
}
}
.title {
white-space: nowrap;
> a {
&:last-of-type {
font-weight: 600;
}
}
}
.avatar-tile {
margin-right: 5px;
border: 1px solid $border-color;
border-radius: 50%;
vertical-align: sub;
&.identicon {
float: left;
width: 16px;
height: 16px;
margin-top: 2px;
font-size: 10px;
}
}
.text-expander {
margin-left: 4px;
margin-right: 4px;
> i {
position: relative;
top: 1px;
}
}
}
.breadcrumbs-extra {
flex: 0 0 auto;
margin-left: auto;
}
.breadcrumbs-sub-title {
margin: 2px 0 0;
font-size: 16px;
font-weight: normal;
ul {
margin: 0;
}
li {
display: inline-block;
&:not(:last-child) {
&::after {
content: "/";
margin: 0 2px 0 5px;
}
}
&:last-child a {
font-weight: 600;
}
}
a {
color: $gl-text-color;
}
}
......@@ -49,6 +49,7 @@ $new-sidebar-width: 220px;
position: fixed;
z-index: 400;
width: $new-sidebar-width;
transition: width $sidebar-transition-duration;
top: 50px;
bottom: 0;
left: 0;
......@@ -62,6 +63,8 @@ $new-sidebar-width: 220px;
}
li {
white-space: nowrap;
a {
display: block;
padding: 12px 14px;
......@@ -72,6 +75,10 @@ $new-sidebar-width: 220px;
color: $gl-text-color;
text-decoration: none;
}
@media (max-width: $screen-xs-max) {
width: 0;
}
}
.sidebar-sub-level-items {
......
......@@ -20,8 +20,6 @@
}
.diff-content {
overflow: auto;
overflow-y: hidden;
background: $white-light;
color: $gl-text-color;
border-radius: 0 0 3px 3px;
......@@ -476,6 +474,7 @@
height: 19px;
width: 19px;
margin-left: -15px;
z-index: 100;
&:hover {
.diff-comment-avatar,
......@@ -491,7 +490,7 @@
transform: translateX((($i * $x-pos) - $x-pos));
&:hover {
transform: translateX((($i * $x-pos) - $x-pos)) scale(1.2);
transform: translateX((($i * $x-pos) - $x-pos));
}
}
}
......@@ -542,6 +541,7 @@
height: 19px;
padding: 0;
transition: transform .1s ease-out;
z-index: 100;
svg {
position: absolute;
......@@ -555,10 +555,6 @@
fill: $white-light;
}
&:hover {
transform: scale(1.2);
}
&:focus {
outline: 0;
}
......
......@@ -628,8 +628,14 @@ ul.notes {
* Line note button on the side of diffs
*/
.line_holder .is-over:not(.no-comment-btn) {
.add-diff-note {
opacity: 1;
}
}
.add-diff-note {
display: none;
opacity: 0;
margin-top: -2px;
border-radius: 50%;
background: $white-light;
......@@ -642,13 +648,11 @@ ul.notes {
width: 23px;
height: 23px;
border: 1px solid $blue-500;
transition: transform .1s ease-in-out;
&:hover {
background: $blue-500;
border-color: $blue-600;
color: $white-light;
transform: scale(1.15);
}
&:active {
......
......@@ -20,6 +20,7 @@
#
class IssuableFinder
NONE = '0'.freeze
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page].freeze
attr_accessor :current_user, :params
......@@ -62,7 +63,7 @@ class IssuableFinder
# grouping and counting within that query.
#
def count_by_state
count_params = params.merge(state: nil, sort: nil)
count_params = params.merge(state: nil, sort: nil, for_counting: true)
labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params)
counts = Hash.new(0)
......@@ -86,6 +87,10 @@ class IssuableFinder
execute.find_by!(*params)
end
def state_counter_cache_key(state)
Digest::SHA1.hexdigest(state_counter_cache_key_components(state).flatten.join('-'))
end
def group
return @group if defined?(@group)
......@@ -418,4 +423,13 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
def state_counter_cache_key_components(state)
opts = params.with_indifferent_access
opts[:state] = state
opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
opts.delete_if { |_, value| value.blank? }
['issuables_count', klass.to_ability_name, opts.sort]
end
end
......@@ -16,14 +16,72 @@
# sort: string
#
class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
def klass
Issue
end
def with_confidentiality_access_check
return Issue.all if user_can_see_all_confidential_issues?
return Issue.where('issues.confidential IS NOT TRUE') if user_cannot_see_confidential_issues?
Issue.where('
issues.confidential IS NOT TRUE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
OR issues.project_id IN(:project_ids)))',
user_id: current_user.id,
project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
end
private
def init_collection
IssuesFinder.not_restricted_by_confidentiality(current_user)
with_confidentiality_access_check
end
def user_can_see_all_confidential_issues?
return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues)
return @user_can_see_all_confidential_issues = false if current_user.blank?
return @user_can_see_all_confidential_issues = true if current_user.full_private_access?
@user_can_see_all_confidential_issues =
project? &&
project &&
project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
end
# Anonymous users can't see any confidential issues.
#
# Users without access to see _all_ confidential issues (as in
# `user_can_see_all_confidential_issues?`) are more complicated, because they
# can see confidential issues where:
# 1. They are an assignee.
# 2. They are an author.
#
# That's fine for most cases, but if we're just counting, we need to cache
# effectively. If we cached this accurately, we'd have a cache key for every
# authenticated user without sufficient access to the project. Instead, when
# we are counting, we treat them as if they can't see any confidential issues.
#
# This does mean the counts may be wrong for those users, but avoids an
# explosion in cache keys.
def user_cannot_see_confidential_issues?(for_counting: false)
return false if user_can_see_all_confidential_issues?
current_user.blank? || for_counting || params[:for_counting]
end
def state_counter_cache_key_components(state)
extra_components = [
user_can_see_all_confidential_issues?,
user_cannot_see_confidential_issues?(for_counting: true)
]
super + extra_components
end
def by_assignee(items)
......@@ -38,21 +96,6 @@ class IssuesFinder < IssuableFinder
end
end
def self.not_restricted_by_confidentiality(user)
return Issue.where('issues.confidential IS NOT TRUE') if user.blank?
return Issue.all if user.full_private_access?
Issue.where('
issues.confidential IS NOT TRUE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
OR issues.project_id IN(:project_ids)))',
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
end
def item_project_ids(items)
items&.reorder(nil)&.select(:project_id)
end
......
......@@ -16,11 +16,12 @@ module GroupsHelper
full_title = ''
group.ancestors.reverse.each do |parent|
full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable')
full_title += group_title_link(parent, hidable: true)
full_title += '<span class="hidable"> / </span>'.html_safe
end
full_title += link_to(simple_sanitize(group.name), group_path(group), class: 'group-path')
full_title += group_title_link(group)
full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path') if name
content_tag :span, class: 'group-title' do
......@@ -56,4 +57,20 @@ module GroupsHelper
def group_issues(group)
IssuesFinder.new(current_user, group_id: group.id).execute
end
private
def group_title_link(group, hidable: false)
link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do
output =
if show_new_nav?
image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16)
else
""
end
output << simple_sanitize(group.name)
output.html_safe
end
end
end
......@@ -165,11 +165,7 @@ module IssuablesHelper
}
state_title = titles[state] || state.to_s.humanize
count =
Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
issuables_count_for_state(issuable_type, state)
end
count = issuables_count_for_state(issuable_type, state)
html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
......@@ -237,6 +233,18 @@ module IssuablesHelper
}
end
def issuables_count_for_state(issuable_type, state, finder: nil)
finder ||= public_send("#{issuable_type}_finder")
cache_key = finder.state_counter_cache_key(state)
@counts ||= {}
@counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
finder.count_by_state
end
@counts[cache_key][state]
end
private
def sidebar_gutter_collapsed?
......@@ -255,24 +263,6 @@ module IssuablesHelper
end
end
def issuables_count_for_state(issuable_type, state)
@counts ||= {}
@counts[issuable_type] ||= public_send("#{issuable_type}_finder").count_by_state
@counts[issuable_type][state]
end
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page].freeze
private_constant :IRRELEVANT_PARAMS_FOR_CACHE_KEY
def issuables_state_counter_cache_key(issuable_type, state)
opts = params.with_indifferent_access
opts[:state] = state
opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
opts.delete_if { |_, value| value.blank? }
hexdigest(['issuables_count', issuable_type, opts.sort].flatten.join('-'))
end
def issuable_templates(issuable)
@issuable_templates ||=
case issuable
......
......@@ -47,6 +47,18 @@ module NotesHelper
data
end
def add_diff_note_button(line_code, position, line_type)
return if @diff_notes_disabled
button_tag '',
class: 'add-diff-note js-add-diff-note-button',
type: 'submit', name: 'button',
data: diff_view_line_data(line_code, position, line_type),
title: 'Add a comment to this line' do
icon('comment-o')
end
end
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
......
......@@ -58,7 +58,17 @@ module ProjectsHelper
link_to(simple_sanitize(owner.name), user_path(owner))
end
project_link = link_to simple_sanitize(project.name), project_path(project), { class: "project-item-select-holder" }
project_link = link_to project_path(project), { class: "project-item-select-holder" } do
output =
if show_new_nav?
project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16)
else
""
end
output << simple_sanitize(project.name)
output.html_safe
end
if current_user
project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do
......
......@@ -11,20 +11,29 @@ module WebpackHelper
paths = Webpack::Rails::Manifest.asset_paths(source)
if extension
paths = paths.select { |p| p.ends_with? ".#{extension}" }
paths.select! { |p| p.ends_with? ".#{extension}" }
end
# include full webpack-dev-server url for rspec tests running locally
force_host = webpack_public_host
if force_host
paths.map! { |p| "#{force_host}#{p}" }
end
paths
end
def webpack_public_host
if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled
host = Rails.configuration.webpack.dev_server.host
port = Rails.configuration.webpack.dev_server.port
protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http'
paths.map! do |p|
"#{protocol}://#{host}:#{port}#{p}"
"#{protocol}://#{host}:#{port}"
else
ActionController::Base.asset_host.try(:chomp, '/')
end
end
paths
def webpack_public_path
"#{webpack_public_host}/#{Rails.application.config.webpack.public_path}/"
end
end
......@@ -14,7 +14,7 @@ module Mentionable
end
EXTERNAL_PATTERN = begin
issue_pattern = ExternalIssue.reference_pattern
issue_pattern = IssueTrackerService.reference_pattern
link_patterns = URI.regexp(%w(http https))
reference_pattern(link_patterns, issue_pattern)
end
......
......@@ -38,11 +38,6 @@ class ExternalIssue
@project.id
end
# Pattern used to extract `JIRA-123` issue references from text
def self.reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
end
def to_reference(_from_project = nil, full: nil)
id
end
......
......@@ -737,8 +737,8 @@ class Project < ActiveRecord::Base
end
end
def issue_reference_pattern
issues_tracker.reference_pattern
def external_issue_reference_pattern
external_issue_tracker.class.reference_pattern
end
def default_issues_tracker?
......
......@@ -5,7 +5,10 @@ class IssueTrackerService < Service
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
def reference_pattern
# This pattern does not support cross-project references
# The other code assumes that this pattern is a superset of all
# overriden patterns. See ReferenceRegexes::EXTERNAL_PATTERN
def self.reference_pattern
@reference_pattern ||= %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
end
......
......@@ -18,7 +18,7 @@ class JiraService < IssueTrackerService
end
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def reference_pattern
def self.reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
end
......
......@@ -3,8 +3,8 @@ class GitHooksService
attr_accessor :oldrev, :newrev, :ref
def execute(user, repo_path, oldrev, newrev, ref)
@repo_path = repo_path
def execute(user, project, oldrev, newrev, ref)
@project = project
@user = Gitlab::GlId.gl_id(user)
@oldrev = oldrev
@newrev = newrev
......@@ -26,7 +26,7 @@ class GitHooksService
private
def run_hook(name)
hook = Gitlab::Git::Hook.new(name, @repo_path)
hook = Gitlab::Git::Hook.new(name, @project)
hook.trigger(@user, oldrev, newrev, ref)
end
end
......@@ -120,7 +120,7 @@ class GitOperationService
def with_hooks(ref, newrev, oldrev)
GitHooksService.new.execute(
user,
repository.path_to_repo,
repository.project,
oldrev,
newrev,
ref) do |service|
......
- @hide_top_links = true
- @no_container = true
= content_for :meta_tags do
......
- @hide_top_links = true
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
= render 'dashboard/groups_head'
......
- @hide_top_links = true
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
......
- @no_container = true
- @hide_top_links = true
- @breadcrumb_title = "Projects"
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
......
- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", dashboard_snippets_path
......
......@@ -38,7 +38,7 @@
= Gon::Base.render_data
= webpack_bundle_tag "runtime"
= webpack_bundle_tag "webpack_runtime"
= webpack_bundle_tag "common"
= webpack_bundle_tag "locale"
= webpack_bundle_tag "main"
......
......@@ -14,6 +14,8 @@
= render "layouts/broadcast"
= render "layouts/flash"
= yield :flash_message
- if show_new_nav?
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= yield
......@@ -17,7 +17,7 @@
= link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do
= brand_header_logo
.title-container
.title-container.js-title-container
%h1.title{ class: ('initializing' if @has_group_title) }= title
.navbar-collapse.collapse
......
......@@ -83,8 +83,6 @@
= icon('ellipsis-v', class: 'js-navbar-toggle-right')
= icon('times', class: 'js-navbar-toggle-left', style: 'display: none;')
= yield :header_content
= render 'shared/outdated_browser'
- if @project && !@project.empty_repo?
......
- breadcrumb_title = @breadcrumb_title || controller.controller_name.humanize
- hide_top_links = @hide_top_links || false
%nav.breadcrumbs{ role: "navigation" }
.breadcrumbs-container{ class: container_class }
.breadcrumbs-links.js-title-container
- unless hide_top_links
.title
= link_to "GitLab", root_path
\/
= header_title
%h2.breadcrumbs-sub-title
%ul.list-unstyled
- if content_for?(:sub_title_before)
= yield :sub_title_before
%li= link_to breadcrumb_title, request.path
- if content_for?(:breadcrumbs_extra)
.breadcrumbs-extra.hidden-xs= yield :breadcrumbs_extra
= yield :header_content
......@@ -28,7 +28,7 @@
%span
Issues
- if @project.default_issues_tracker?
%span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count)
%span.badge.count.issue_counter= number_with_delimiter(issuables_count_for_state(:issues, :opened, finder: IssuesFinder.new(current_user, project_id: @project.id)))
- if project_nav_tab? :merge_requests
- controllers = [:merge_requests, 'projects/merge_requests/conflicts']
......@@ -37,7 +37,7 @@
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
%span
Merge Requests
%span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count)
%span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(issuables_count_for_state(:merge_requests, :opened, finder: MergeRequestsFinder.new(current_user, project_id: @project.id)))
- if project_nav_tab? :pipelines
= nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do
......
......@@ -2,6 +2,10 @@
- @content_class = "issue-boards-content"
- page_title "Boards"
- if show_new_nav?
- content_for :sub_title_before do
%li= link_to "Issues", namespace_project_issues_path(@project.namespace, @project)
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
......
......@@ -19,6 +19,7 @@
- if plain
= link_text
- else
= add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
......@@ -29,7 +30,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
%td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
%td.line_content.noteable_line{ class: type }<
- if email
%pre= line.text
- else
......
/ Side-by-side diff view
.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data }
%table
- diff_file.parallel_diff_lines.each do |line|
......@@ -18,11 +19,12 @@
- left_line_code = diff_file.line_code(left)
- left_position = diff_file.position(left)
%td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } }
= add_diff_note_button(left_line_code, left_position, 'old')
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
- discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text)
%td.line_content.parallel.noteable_line{ class: left.type }= diff_line_content(left.text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
......@@ -38,11 +40,12 @@
- right_line_code = diff_file.line_code(right)
- right_position = diff_file.position(right)
%td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } }
= add_diff_note_button(right_line_code, right_position, 'new')
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
- discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text)
%td.line_content.parallel.noteable_line{ class: right.type }= diff_line_content(right.text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
......
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
- if @can_bulk_update
= button_tag "Edit Issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
= link_to "New issue", new_namespace_project_issue_path(@project.namespace,
@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
milestone_id: issues_finder.milestones.first.try(:id) }),
class: "btn btn-new",
title: "New issue",
id: "new_issue_link"
......@@ -13,23 +13,16 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render "projects/issues/nav_btns"
- if project_issues(@project).exists?
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
- if @can_bulk_update
= button_tag "Edit Issues", class: "btn btn-default js-bulk-update-toggle"
= link_to new_namespace_project_issue_path(@project.namespace,
@project,
issue: { assignee_id: issues_finder.assignee.try(:id),
milestone_id: issues_finder.milestones.first.try(:id) }),
class: "btn btn-new",
title: "New issue",
id: "new_issue_link" do
New issue
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
= render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
......
- if @can_bulk_update
= button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle"
- if merge_project
= link_to new_merge_request_path, class: "btn btn-new", title: "New merge request" do
New merge request
- @no_container = true
- @can_bulk_update = can?(current_user, :admin_merge_request, @project)
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- new_merge_request_path = namespace_project_new_merge_request_path(merge_project.namespace, merge_project) if merge_project
- page_title "Merge Requests"
- unless @project.default_issues_tracker?
......@@ -10,6 +12,9 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'projects/last_push'
......@@ -17,13 +22,8 @@
%div{ class: container_class }
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
- if @can_bulk_update
= button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle"
- merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project))
- if merge_project
= link_to namespace_project_new_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do
New merge request
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
= render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'shared/issuable/search_bar', type: :merge_requests
......@@ -33,4 +33,4 @@
.merge-requests-holder
= render 'merge_requests'
- else
= render 'shared/empty_states/merge_requests', button_path: namespace_project_new_merge_request_path(@project.namespace, @project)
= render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
- if @issues.to_a.any?
.panel.panel-default.panel-small.panel-without-border
%ul.content-list.issues-list
%ul.content-list.issues-list.issuable-list
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
......
- if @merge_requests.to_a.any?
.panel.panel-default.panel-small.panel-without-border
%ul.content-list.mr-list
%ul.content-list.mr-list.issuable-list
= render partial: 'projects/merge_requests/merge_request', collection: @merge_requests
= paginate @merge_requests, theme: "gitlab"
......
---
title: Improve support for external issue references
merge_request: 12485
author:
---
title: Fix diff of requirements.txt file by not matching newlines as part of package
names
merge_request:
author:
---
title: Fix 'New merge request' button for users who don't have push access to canonical
project
merge_request:
author:
---
title: Enable support for webpack code-splitting by dynamically setting publicPath
at runtime
merge_request: 12032
author:
---
title: Perform housekeeping only when an import of a fresh project is completed
merge_request:
author:
---
title: Add issuable-list class to shared mr/issue lists to fix new responsive layout
design
merge_request:
author:
---
title: Fix head pipeline stored in merge request for external pipelines
merge_request: 12478
author:
---
title: Fixed sidebar not collapsing on merge requests in mobile screens
merge_request:
author:
---
title: Bring back branches badge to main project page
merge_request: 12548
author:
---
title: Fixed issue boards closed list not showing all closed issues
merge_request:
author:
---
title: Fixed multi-line markdown tooltip buttons in issue edit form
merge_request:
author:
---
title: Cache open issue and merge request counts for project tabs to speed up project
pages
merge_request: 12457
author:
......@@ -71,6 +71,7 @@ var config = {
vue_merge_request_widget: './vue_merge_request_widget/index.js',
test: './test.js',
peek: './peek.js',
webpack_runtime: './webpack.js',
},
output: {
......@@ -190,7 +191,7 @@ var config = {
// create cacheable common library bundles
new webpack.optimize.CommonsChunkPlugin({
names: ['main', 'locale', 'common', 'runtime'],
names: ['main', 'locale', 'common', 'webpack_runtime'],
}),
],
......@@ -245,7 +246,6 @@ if (IS_DEV_SERVER) {
hot: DEV_SERVER_LIVERELOAD,
inline: DEV_SERVER_LIVERELOAD
};
config.output.publicPath = '//' + DEV_SERVER_HOST + ':' + DEV_SERVER_PORT + config.output.publicPath;
config.plugins.push(
// watch node_modules for changes if we encounter a missing module compile error
new WatchMissingNodeModulesPlugin(path.join(ROOT_PATH, 'node_modules'))
......
......@@ -8,6 +8,9 @@ you to do the following:
issue index of the external tracker
- clicking **New issue** on the project dashboard creates a new issue on the
external tracker
- you can reference these external issues inside GitLab interface
(merge requests, commits, comments) and they will be automatically converted
into links
## Configuration
......
doc/user/project/img/issue_board.png

74.7 KB | W: | H:

doc/user/project/img/issue_board.png

50.2 KB | W: | H:

doc/user/project/img/issue_board.png
doc/user/project/img/issue_board.png
doc/user/project/img/issue_board.png
doc/user/project/img/issue_board.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -16,3 +16,14 @@ Once you have configured and enabled Bugzilla:
- the **Issues** link on the GitLab project pages takes you to the appropriate
Bugzilla product page
- clicking **New issue** on the project dashboard takes you to Bugzilla for entering a new issue
## Referencing issues in Bugzilla
Issues in Bugzilla can be referenced in two alternative ways:
1. `#<ID>` where `<ID>` is a number (example `#143`)
2. `<PROJECT>-<ID>` where `<PROJECT>` starts with a capital letter which is
then followed by capital letters, numbers or underscores, and `<ID>` is
a number (example `API_32-143`).
Please note that `<PROJECT>` part is ignored and links always point to the
address specified in `issues_url`.
......@@ -21,3 +21,14 @@ Once you have configured and enabled Redmine:
As an example, below is a configuration for a project named gitlab-ci.
![Redmine configuration](img/redmine_configuration.png)
## Referencing issues in Redmine
Issues in Redmine can be referenced in two alternative ways:
1. `#<ID>` where `<ID>` is a number (example `#143`)
2. `<PROJECT>-<ID>` where `<PROJECT>` starts with a capital letter which is
then followed by capital letters, numbers or underscores, and `<ID>` is
a number (example `API_32-143`).
Please note that `<PROJECT>` part is ignored and links always point to the
address specified in `issues_url`.
......@@ -232,7 +232,7 @@ module SharedDiffNote
end
def click_parallel_diff_line(code, line_type)
find(".line_content.parallel.#{line_type}[data-line-code='#{code}']").trigger 'mouseover'
find(".line_holder.parallel .diff-line-num[id='#{code}']").trigger 'mouseover'
find(".line_holder.parallel button[data-line-code='#{code}']").trigger 'click'
end
end
......@@ -216,12 +216,7 @@ module Banzai
@references_per_project ||= begin
refs = Hash.new { |hash, key| hash[key] = Set.new }
regex =
if uses_reference_pattern?
Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
else
object_class.link_reference_pattern
end
regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
nodes.each do |node|
node.to_html.scan(regex) do
......@@ -323,14 +318,6 @@ module Banzai
value
end
end
# There might be special cases like filters
# that should ignore reference pattern
# eg: IssueReferenceFilter when using a external issues tracker
# In those cases this method should be overridden on the filter subclass
def uses_reference_pattern?
true
end
end
end
end
......@@ -3,6 +3,8 @@ module Banzai
# HTML filter that replaces external issue tracker references with links.
# References are ignored if the project doesn't use an external issue
# tracker.
#
# This filter does not support cross-project references.
class ExternalIssueReferenceFilter < ReferenceFilter
self.reference_type = :external_issue
......@@ -87,7 +89,7 @@ module Banzai
end
def issue_reference_pattern
external_issues_cached(:issue_reference_pattern)
external_issues_cached(:external_issue_reference_pattern)
end
private
......
......@@ -15,10 +15,6 @@ module Banzai
Issue
end
def uses_reference_pattern?
context[:project].default_issues_tracker?
end
def find_object(project, iid)
issues_per_project[project][iid]
end
......@@ -38,13 +34,7 @@ module Banzai
projects_per_reference.each do |path, project|
issue_ids = references_per_project[path]
issues =
if project.default_issues_tracker?
project.issues.where(iid: issue_ids.to_a)
else
issue_ids.map { |id| ExternalIssue.new(id, project) }
end
issues = project.issues.where(iid: issue_ids.to_a)
issues.each do |issue|
hash[project][issue.iid.to_i] = issue
......@@ -55,26 +45,6 @@ module Banzai
end
end
def object_link_title(object)
if object.is_a?(ExternalIssue)
"Issue in #{object.project.external_issue_tracker.title}"
else
super
end
end
def data_attributes_for(text, project, object, link: false)
if object.is_a?(ExternalIssue)
data_attribute(
project: project.id,
external_issue: object.id,
reference_type: ExternalIssueReferenceFilter.reference_type
)
else
super
end
end
def projects_relation_for_paths(paths)
super(paths).includes(:gitlab_issue_tracker_service)
end
......
......@@ -4,9 +4,6 @@ module Banzai
self.reference_type = :issue
def nodes_visible_to_user(user, nodes)
# It is not possible to check access rights for external issue trackers
return nodes if project && project.external_issue_tracker
issues = issues_for_nodes(nodes)
readable_issues = Ability
......
......@@ -15,7 +15,7 @@ module Gitlab
validates :config, allowed_keys: ALLOWED_KEYS
validates :name, type: String, presence: true
validates :entrypoint, type: String, allow_nil: true
validates :entrypoint, array_of_strings: true, allow_nil: true
end
def hash?
......
......@@ -15,8 +15,8 @@ module Gitlab
validates :config, allowed_keys: ALLOWED_KEYS
validates :name, type: String, presence: true
validates :entrypoint, type: String, allow_nil: true
validates :command, type: String, allow_nil: true
validates :entrypoint, array_of_strings: true, allow_nil: true
validates :command, array_of_strings: true, allow_nil: true
validates :alias, type: String, allow_nil: true
end
......
......@@ -4,9 +4,10 @@ module Gitlab
GL_PROTOCOL = 'web'.freeze
attr_reader :name, :repo_path, :path
def initialize(name, repo_path)
def initialize(name, project)
@name = name
@repo_path = repo_path
@project = project
@repo_path = project.repository.path
@path = File.join(repo_path.strip, 'hooks', name)
end
......@@ -38,7 +39,8 @@ module Gitlab
vars = {
'GL_ID' => gl_id,
'PWD' => repo_path,
'GL_PROTOCOL' => GL_PROTOCOL
'GL_PROTOCOL' => GL_PROTOCOL,
'GL_REPOSITORY' => Gitlab::GlRepository.gl_repository(@project, false)
}
options = {
......
......@@ -2,11 +2,14 @@
module Gitlab
module GonHelper
include WebpackHelper
def add_gon_variables
gon.api_version = 'v4'
gon.default_avatar_url = URI.join(Gitlab.config.gitlab.url, ActionController::Base.helpers.image_path('no_avatar.png')).to_s
gon.max_file_size = current_application_settings.max_attachment_size
gon.asset_host = ActionController::Base.asset_host
gon.webpack_public_path = webpack_public_path
gon.relative_url_root = Gitlab.config.gitlab.relative_url_root
gon.shortcuts_path = help_page_path('shortcuts')
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
......
......@@ -220,7 +220,7 @@ FactoryGirl.define do
active: true,
properties: {
'project_url' => 'http://redmine/projects/project_name_in_redmine',
'issues_url' => "http://redmine/#{project.id}/project_name_in_redmine/:id",
'issues_url' => 'http://redmine/projects/project_name_in_redmine/issues/:id',
'new_issue_url' => 'http://redmine/projects/project_name_in_redmine/issues/new'
}
)
......
......@@ -129,7 +129,7 @@ feature 'Expand and collapse diffs', js: true, feature: true do
before do
large_diff.find('.diff-line-num', match: :prefer_exact).hover
large_diff.find('.add-diff-note').click
large_diff.find('.add-diff-note', match: :prefer_exact).click
large_diff.find('.note-textarea').send_keys comment_text
large_diff.find_button('Comment').click
wait_for_requests
......
require 'rails_helper'
describe 'Issue Sidebar on Mobile' do
include MobileHelpers
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:issue) { create(:issue, project: project) }
let!(:user) { create(:user)}
before do
sign_in(user)
end
context 'mobile sidebar on merge requests', js: true do
before do
visit namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request)
end
it_behaves_like "issue sidebar stays collapsed on mobile"
end
context 'mobile sidebar on issues', js: true do
before do
visit namespace_project_issue_path(project.namespace, project, issue)
end
it_behaves_like "issue sidebar stays collapsed on mobile"
end
end
......@@ -154,20 +154,6 @@ feature 'Issue Sidebar', feature: true do
end
end
context 'as a allowed mobile user', js: true do
before do
project.team << [user, :developer]
resize_screen_xs
visit_issue(project, issue)
end
context 'mobile sidebar' do
it 'collapses the sidebar for small screens' do
expect(page).not_to have_css('aside.right-sidebar.right-sidebar-collapsed')
end
end
end
context 'as a guest' do
before do
project.team << [user, :guest]
......
......@@ -98,6 +98,6 @@ feature 'Import/Export - project import integration test', feature: true, js: tr
end
def project_hook_exists?(project)
Gitlab::Git::Hook.new('post-receive', project.repository.path).exists?
Gitlab::Git::Hook.new('post-receive', project).exists?
end
end
......@@ -295,22 +295,121 @@ describe IssuesFinder do
end
end
describe '.not_restricted_by_confidentiality' do
let(:authorized_user) { create(:user) }
let(:project) { create(:empty_project, namespace: authorized_user.namespace) }
let!(:public_issue) { create(:issue, project: project) }
let!(:confidential_issue) { create(:issue, project: project, confidential: true) }
describe '#with_confidentiality_access_check' do
let(:guest) { create(:user) }
set(:authorized_user) { create(:user) }
set(:project) { create(:empty_project, namespace: authorized_user.namespace) }
set(:public_issue) { create(:issue, project: project) }
set(:confidential_issue) { create(:issue, project: project, confidential: true) }
context 'when no project filter is given' do
let(:params) { {} }
context 'for an anonymous user' do
subject { described_class.new(nil, params).with_confidentiality_access_check }
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
end
context 'for a user without project membership' do
subject { described_class.new(user, params).with_confidentiality_access_check }
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
end
context 'for a guest user' do
subject { described_class.new(guest, params).with_confidentiality_access_check }
before do
project.add_guest(guest)
end
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
end
context 'for a project member with access to view confidential issues' do
subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
it 'returns all issues' do
expect(subject).to include(public_issue, confidential_issue)
end
end
end
context 'when searching within a specific project' do
let(:params) { { project_id: project.id } }
context 'for an anonymous user' do
subject { described_class.new(nil, params).with_confidentiality_access_check }
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
it 'returns non confidential issues for nil user' do
expect(described_class.send(:not_restricted_by_confidentiality, nil)).to include(public_issue)
subject
end
end
context 'for a user without project membership' do
subject { described_class.new(user, params).with_confidentiality_access_check }
it 'returns non confidential issues for user not authorized for the issues projects' do
expect(described_class.send(:not_restricted_by_confidentiality, user)).to include(public_issue)
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
it 'returns all issues for user authorized for the issues projects' do
expect(described_class.send(:not_restricted_by_confidentiality, authorized_user)).to include(public_issue, confidential_issue)
it 'filters by confidentiality' do
expect(Issue).to receive(:where).with(a_string_matching('confidential'), anything)
subject
end
end
context 'for a guest user' do
subject { described_class.new(guest, params).with_confidentiality_access_check }
before do
project.add_guest(guest)
end
it 'returns only public issues' do
expect(subject).to include(public_issue)
expect(subject).not_to include(confidential_issue)
end
it 'filters by confidentiality' do
expect(Issue).to receive(:where).with(a_string_matching('confidential'), anything)
subject
end
end
context 'for a project member with access to view confidential issues' do
subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
it 'returns all issues' do
expect(subject).to include(public_issue, confidential_issue)
end
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
subject
end
end
end
end
end
......@@ -91,7 +91,7 @@ describe GroupsHelper do
let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
it 'outputs the groups in the correct order' do
expect(group_title(very_deep_nested_group)).to match(/>#{group.name}<\/a>.*>#{nested_group.name}<\/a>.*>#{deep_nested_group.name}<\/a>/)
expect(helper.group_title(very_deep_nested_group)).to match(/>#{group.name}<\/a>.*>#{nested_group.name}<\/a>.*>#{deep_nested_group.name}<\/a>/)
end
end
end
......@@ -77,54 +77,89 @@ describe IssuablesHelper do
}.with_indifferent_access
end
let(:issues_finder) { IssuesFinder.new(nil, params) }
let(:merge_requests_finder) { MergeRequestsFinder.new(nil, params) }
before do
allow(helper).to receive(:issues_finder).and_return(issues_finder)
allow(helper).to receive(:merge_requests_finder).and_return(merge_requests_finder)
end
it 'returns the cached value when called for the same issuable type & with the same params' do
expect(helper).to receive(:params).twice.and_return(params)
expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
expect(helper).not_to receive(:issuables_count_for_state)
expect(issues_finder).not_to receive(:count_by_state)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
end
it 'takes confidential status into account when searching for issues' do
expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to include('42')
expect(issues_finder).to receive(:user_cannot_see_confidential_issues?).twice.and_return(false)
expect(issues_finder).to receive(:count_by_state).and_return(opened: 40)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to include('40')
expect(issues_finder).to receive(:user_can_see_all_confidential_issues?).and_return(true)
expect(issues_finder).to receive(:count_by_state).and_return(opened: 45)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to include('45')
end
it 'does not take confidential status into account when searching for merge requests' do
expect(merge_requests_finder).to receive(:count_by_state).and_return(opened: 42)
expect(merge_requests_finder).not_to receive(:user_cannot_see_confidential_issues?)
expect(merge_requests_finder).not_to receive(:user_can_see_all_confidential_issues?)
expect(helper.issuables_state_counter_text(:merge_requests, :opened))
.to include('42')
end
it 'does not take some keys into account in the cache key' do
expect(helper).to receive(:params).and_return({
expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
expect(issues_finder).to receive(:params).and_return({
author_id: '11',
state: 'foo',
sort: 'foo',
utf8: 'foo',
page: 'foo'
}.with_indifferent_access)
expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
expect(helper).to receive(:params).and_return({
expect(issues_finder).not_to receive(:count_by_state)
expect(issues_finder).to receive(:params).and_return({
author_id: '11',
state: 'bar',
sort: 'bar',
utf8: 'bar',
page: 'bar'
}.with_indifferent_access)
expect(helper).not_to receive(:issuables_count_for_state)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
end
it 'does not take params order into account in the cache key' do
expect(helper).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
expect(issues_finder).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
expect(issues_finder).to receive(:count_by_state).and_return(opened: 42)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
expect(helper).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
expect(helper).not_to receive(:issuables_count_for_state)
expect(issues_finder).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
expect(issues_finder).not_to receive(:count_by_state)
expect(helper.issuables_state_counter_text(:issues, :opened))
.to eq('<span>Open</span> <span class="badge">42</span>')
......
/* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */
import Cookies from 'js-cookie';
import AwardsHandler from '~/awards_handler';
import loadAwardsHandler from '~/awards_handler';
import '~/lib/utils/common_utils';
......@@ -26,14 +26,13 @@ import '~/lib/utils/common_utils';
describe('AwardsHandler', function() {
preloadFixtures('issues/issue_with_comment.html.raw');
beforeEach(function() {
beforeEach(function(done) {
loadFixtures('issues/issue_with_comment.html.raw');
awardsHandler = new AwardsHandler;
spyOn(awardsHandler, 'postEmoji').and.callFake((function(_this) {
return function(button, url, emoji, cb) {
return cb();
};
})(this));
loadAwardsHandler(true).then((obj) => {
awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
done();
}).catch(fail);
let isEmojiMenuBuilt = false;
openAndWaitForEmojiMenu = function() {
......
......@@ -42,9 +42,6 @@ describe('Issuable output', () => {
}).$mount();
});
afterEach(() => {
});
it('should render a title/description/edited and update title/description/edited on update', (done) => {
vm.poll.options.successCallback({
json() {
......
......@@ -523,6 +523,51 @@ import '~/notes';
});
});
describe('postComment with Slash commands', () => {
const sampleComment = '/assign @root\n/award :100:';
const note = {
commands_changes: {
assignee_id: 1,
emoji_award: '100'
},
errors: {
commands_only: ['Commands applied']
},
valid: false
};
let $form;
let $notesContainer;
beforeEach(() => {
this.notes = new Notes('', []);
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
gl.awardsHandler = {
addAwardToEmojiBar: () => {},
scrollToAwards: () => {}
};
gl.GfmAutoComplete = {
dataSources: {
commands: '/root/test-project/autocomplete_sources/commands'
}
};
$form = $('form.js-main-target-form');
$notesContainer = $('ul.main-notes-list');
$form.find('textarea.js-note-text').val(sampleComment);
});
it('should remove slash command placeholder when comment with slash commands is done posting', () => {
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
spyOn(gl.awardsHandler, 'addAwardToEmojiBar').and.callThrough();
$('.js-comment-button').click();
expect($notesContainer.find('.system-note.being-posted').length).toEqual(1); // Placeholder shown
deferred.resolve(note);
expect($notesContainer.find('.system-note.being-posted').length).toEqual(0); // Placeholder removed
});
});
describe('update comment with script tags', () => {
const sampleComment = '<script></script>';
const updatedComment = '<script></script>';
......
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