Commit 8da939ca authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge remote-tracking branch 'ce-com/master' into ce-to-ee

Signed-off-by: default avatarDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>
parents 2a030c97 9b1e9da9
......@@ -141,21 +141,29 @@ the stable branch are:
* Fixes for security issues
* New or updated translations (as long as they do not touch application code)
During the feature freeze all merge requests that are meant to go into the upcoming
release should have the correct milestone assigned _and_ have the label
~"Pick into Stable" set, so that release managers can find and pick them.
Merge requests without a milestone and this label will
not be merged into any stable branches.
Fixes marked like this will be shipped in the next RC for that release. Once
the final RC has been prepared ready for release on the 22nd, further fixes
marked ~"Pick into Stable" will go into a patch for that release.
If a merge request is to be picked into more than one release it will also need
the ~"Pick into Backports" label set to remind the release manager to change
the milestone after cherry-picking. As before, it should still have the
~"Pick into Stable" label and the milestone of the highest release it will be
picked into.
During the feature freeze all merge requests that are meant to go into the
upcoming release should have the correct milestone assigned _and_ the
`Pick into X.Y` label where `X.Y` is equal to the milestone, so that release
managers can find and pick them.
Merge requests without this label will not be picked into the stable release.
For example, if the upcoming release is `10.2.0` you will need to set the
`Pick into 10.2` label.
Fixes marked like this will be shipped in the next RC (before the 22nd), or the
next patch release.
If a merge request is to be picked into more than one release it will need one
`Pick into X.Y` label per release where the merge request should be back-ported
to.
For example, if the current patch release is `10.1.1` and a regression fix needs
to be backported down to the `9.5` release, you will need to assign it the
`10.1` milestone and the following labels:
- `Pick into 10.1`
- `Pick into 10.0`
- `Pick into 9.5`
### Asking for an exception
......
import { truncate } from './lib/utils/text_utility';
const MAX_MESSAGE_LENGTH = 500;
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
......@@ -15,7 +17,7 @@ export default class AbuseReports {
if (reportMessage.length > MAX_MESSAGE_LENGTH) {
$messageCellElement.data('original-message', reportMessage);
$messageCellElement.data('message-truncated', 'true');
$messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
$messageCellElement.text(truncate(reportMessage, MAX_MESSAGE_LENGTH));
}
}
......
......@@ -3,6 +3,7 @@
import Vue from 'vue';
import Flash from '../../../flash';
import './lists_dropdown';
import { pluralize } from '../../../lib/utils/text_utility';
const ModalStore = gl.issueBoards.ModalStore;
......@@ -21,7 +22,7 @@ gl.issueBoards.ModalFooter = Vue.extend({
submitText() {
const count = ModalStore.selectedCount();
return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`;
return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`;
},
},
methods: {
......
......@@ -3,6 +3,8 @@
prefer-template, object-shorthand, prefer-arrow-callback */
/* global Pager */
import { pluralize } from './lib/utils/text_utility';
export default (function () {
const CommitsList = {};
......@@ -86,7 +88,7 @@ export default (function () {
// Update commits count in the previous commits header.
commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
$commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
$commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${pluralize('commit', commitsCount)}`);
}
gl.utils.localTimeAgo($processedData.find('.js-timeago'));
......
/* eslint-disable func-names, prefer-arrow-callback */
import Api from './api';
import { humanize } from './lib/utils/text_utility';
export default class CreateLabelDropdown {
constructor($el, namespacePath, projectPath) {
......@@ -107,7 +108,7 @@ export default class CreateLabelDropdown {
errors = label.message;
} else {
errors = Object.keys(label.message).map(key =>
`${gl.text.humanize(key)} ${label.message[key].join(', ')}`,
`${humanize(key)} ${label.message[key].join(', ')}`,
).join('<br/>');
}
......
/* eslint-disable no-param-reassign */
import { __ } from '../locale';
import '../lib/utils/text_utility';
import { dasherize } from '../lib/utils/text_utility';
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const EMPTY_STAGE_TEXTS = {
......@@ -36,7 +36,7 @@ export default {
});
newData.stages.forEach((item) => {
const stageSlug = gl.text.dasherize(item.name.toLowerCase());
const stageSlug = dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
......
......@@ -2,7 +2,7 @@
import Timeago from 'timeago.js';
import _ from 'underscore';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import { humanize } from '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
......@@ -134,7 +134,7 @@ export default {
if (this.hasManualActions) {
return this.model.last_deployment.manual_actions.map((action) => {
const parsedAction = {
name: gl.text.humanize(action.name),
name: humanize(action.name),
play_path: action.play_path,
playable: action.playable,
};
......
......@@ -2,6 +2,7 @@
import GfmAutoComplete from './gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import textUtils from './lib/utils/text_markdown';
export default class GLForm {
constructor(form, enableGFM = false) {
......@@ -46,7 +47,7 @@ export default class GLForm {
}
// form and textarea event listeners
this.addEventListeners();
gl.text.init(this.form);
textUtils.init(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
this.form.show();
......@@ -85,7 +86,7 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
gl.text.removeListeners(this.form);
textUtils.removeListeners(this.form);
}
addEventListeners() {
......
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
import 'vendor/jquery.waitforimages';
import '~/lib/utils/text_utility';
import { addDelimiter } from './lib/utils/text_utility';
import Flash from './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
......@@ -73,7 +73,7 @@ export default class Issue {
let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''));
numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
projectIssuesCounter.text(addDelimiter(numProjectIssues));
if (this.createMergeRequestDropdown) {
if (isClosed) {
......
......@@ -14,8 +14,8 @@ export default class Job {
this.state = this.options.logState;
this.buildStage = this.options.buildStage;
this.$document = $(document);
this.$window = $(window);
this.logBytes = 0;
this.hasBeenScrolled = false;
this.updateDropdown = this.updateDropdown.bind(this);
this.$buildTrace = $('#build-trace');
......@@ -54,23 +54,18 @@ export default class Job {
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
$(window)
this.$window
.off('scroll')
.on('scroll', () => {
const contentHeight = this.$buildTraceOutput.height();
if (contentHeight > this.windowSize) {
// means the user did not scroll, the content was updated.
this.windowSize = contentHeight;
} else {
// User scrolled
this.hasBeenScrolled = true;
if (!this.isScrolledToBottom()) {
this.toggleScrollAnimation(false);
} else if (this.isScrolledToBottom() && !this.isLogComplete) {
this.toggleScrollAnimation(true);
}
this.scrollThrottled();
});
$(window)
this.$window
.off('resize.build')
.on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
......@@ -99,14 +94,14 @@ export default class Job {
// eslint-disable-next-line class-methods-use-this
canScroll() {
return $(document).height() > $(window).height();
return this.$document.height() > this.$window.height();
}
toggleScroll() {
const currentPosition = $(document).scrollTop();
const scrollHeight = $(document).height();
const currentPosition = this.$document.scrollTop();
const scrollHeight = this.$document.height();
const windowHeight = $(window).height();
const windowHeight = this.$window.height();
if (this.canScroll()) {
if (currentPosition > 0 &&
(scrollHeight - currentPosition !== windowHeight)) {
......@@ -119,7 +114,7 @@ export default class Job {
this.toggleDisableButton(this.$scrollTopBtn, true);
this.toggleDisableButton(this.$scrollBottomBtn, false);
} else if (scrollHeight - currentPosition === windowHeight) {
} else if (this.isScrolledToBottom()) {
// User is at the bottom of the build log.
this.toggleDisableButton(this.$scrollTopBtn, false);
......@@ -131,9 +126,17 @@ export default class Job {
}
}
isScrolledToBottom() {
const currentPosition = this.$document.scrollTop();
const scrollHeight = this.$document.height();
const windowHeight = this.$window.height();
return scrollHeight - currentPosition === windowHeight;
}
// eslint-disable-next-line class-methods-use-this
scrollDown() {
$(document).scrollTop($(document).height());
this.$document.scrollTop(this.$document.height());
}
scrollToBottom() {
......@@ -143,7 +146,7 @@ export default class Job {
}
scrollToTop() {
$(document).scrollTop(0);
this.$document.scrollTop(0);
this.hasBeenScrolled = true;
this.toggleScroll();
}
......@@ -174,7 +177,7 @@ export default class Job {
this.state = log.state;
}
this.windowSize = this.$buildTraceOutput.height();
this.isScrollInBottom = this.isScrolledToBottom();
if (log.append) {
this.$buildTraceOutput.append(log.html);
......@@ -194,14 +197,9 @@ export default class Job {
} else {
this.$truncatedInfo.addClass('hidden');
}
this.isLogComplete = log.complete;
if (!log.complete) {
if (!this.hasBeenScrolled) {
this.toggleScrollAnimation(true);
} else {
this.toggleScrollAnimation(false);
}
this.timeout = setTimeout(() => {
this.getBuildTrace();
}, 4000);
......@@ -218,7 +216,7 @@ export default class Job {
this.$buildRefreshAnimation.remove();
})
.then(() => {
if (!this.hasBeenScrolled) {
if (this.isScrollInBottom) {
this.scrollDown();
}
})
......
......@@ -172,7 +172,6 @@ export const getSelectedFragment = () => {
return documentFragment;
};
// TODO: Update this name, there is a gl.text.insertText function.
export const insertText = (target, text) => {
// Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
const selectionStart = target.selectionStart;
......
......@@ -2,6 +2,7 @@
import timeago from 'timeago.js';
import dateFormat from 'vendor/date.format';
import { pluralize } from './text_utility';
import {
lang,
......@@ -142,9 +143,9 @@ export function timeIntervalInWords(intervalInSeconds) {
let text = '';
if (minutes >= 1) {
text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`;
} else {
text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
text = `${seconds} ${pluralize('second', seconds)}`;
}
return text;
}
......
/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
const textUtils = {};
textUtils.selectedText = function(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
};
textUtils.lineBefore = function(text, textarea) {
var split;
split = text.substring(0, textarea.selectionStart).trim().split('\n');
return split[split.length - 1];
};
textUtils.lineAfter = function(text, textarea) {
return text.substring(textarea.selectionEnd).trim().split('\n')[0];
};
textUtils.blockTagText = function(text, textArea, blockTag, selected) {
var lineAfter, lineBefore;
lineBefore = this.lineBefore(text, textArea);
lineAfter = this.lineAfter(text, textArea);
if (lineBefore === blockTag && lineAfter === blockTag) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
}
return selected;
} else {
return blockTag + "\n" + selected + "\n" + blockTag;
}
};
textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
// Remove the first newline
if (selected.indexOf('\n') === 0) {
removedFirstNewLine = true;
selected = selected.replace(/\n+/, '');
}
// Remove the last newline
if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
selectedSplit = selected.split('\n');
if (!wrap) {
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty)
if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
currentLineEmpty = true;
}
}
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
insertText = this.blockTagText(text, textArea, blockTag, selected);
} else {
insertText = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
return "" + tag + val;
}
}).join('\n');
}
} else {
insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
if (removedFirstNewLine) {
insertText = '\n' + insertText;
}
if (removedLastNewLine) {
insertText += '\n';
}
if (document.queryCommandSupported('insertText')) {
inserted = document.execCommand('insertText', false, insertText);
}
if (!inserted) {
try {
document.execCommand("ms-beginUndoUnit");
} catch (error) {}
textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
try {
document.execCommand("ms-endUndoUnit");
} catch (error) {}
}
return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
};
textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
var pos;
if (!textArea.setSelectionRange) {
return;
}
if (textArea.selectionStart === textArea.selectionEnd) {
if (wrapped) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
if (removedLastNewLine) {
pos -= 1;
}
return textArea.setSelectionRange(pos, pos);
}
};
textUtils.updateText = function(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = this.selectedText(text, textArea);
$textArea.focus();
return this.insertText(textArea, text, tag, blockTag, selected, wrap);
};
textUtils.init = function(form) {
var self;
self = this;
return $('.js-md', form).off('click').on('click', function() {
var $this;
$this = $(this);
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
textUtils.removeListeners = function(form) {
return $('.js-md', form).off('click');
};
textUtils.replaceRange = function(s, start, end, substitute) {
return s.substring(0, start) + substitute + s.substring(end);
};
export default textUtils;
/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
import 'vendor/latinise';
var base;
var w = window;
if (w.gl == null) {
w.gl = {};
}
if ((base = w.gl).text == null) {
base.text = {};
}
gl.text.addDelimiter = function(text) {
return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text;
};
/**
* Adds a , to a string composed by numbers, at every 3 chars.
*
* 2333 -> 2,333
* 232324 -> 232,324
*
* @param {String} text
* @returns {String}
*/
export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/**
* Returns '99+' for numbers bigger than 99.
......@@ -20,6 +15,7 @@ gl.text.addDelimiter = function(text) {
* @param {Number} count
* @return {Number|String}
*/
<<<<<<< HEAD
export function highCountTrim(count) {
return count > 99 ? '99+' : count;
}
......@@ -105,97 +101,45 @@ gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
}
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
=======
export const highCountTrim = count => (count > 99 ? '99+' : count);
>>>>>>> ce-com/master
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
insertText = this.blockTagText(text, textArea, blockTag, selected);
} else {
insertText = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
return "" + tag + val;
}
}).join('\n');
}
} else {
insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
/**
* Converst first char to uppercase and replaces undercores with spaces
* @param {String} string
* @requires {String}
*/
export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
if (removedFirstNewLine) {
insertText = '\n' + insertText;
}
/**
* Adds an 's' to the end of the string when count is bigger than 0
* @param {String} str
* @param {Number} count
* @returns {String}
*/
export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : '');
if (removedLastNewLine) {
insertText += '\n';
}
/**
* Replaces underscores with dashes
* @param {*} str
* @returns {String}
*/
export const dasherize = str => str.replace(/[_\s]+/g, '-');
if (document.queryCommandSupported('insertText')) {
inserted = document.execCommand('insertText', false, insertText);
}
if (!inserted) {
try {
document.execCommand("ms-beginUndoUnit");
} catch (error) {}
textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
try {
document.execCommand("ms-endUndoUnit");
} catch (error) {}
}
return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
};
gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
var pos;
if (!textArea.setSelectionRange) {
return;
}
if (textArea.selectionStart === textArea.selectionEnd) {
if (wrapped) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
/**
* Removes accents and converts to lower case
* @param {String} str
* @returns {String}
*/
export const slugify = str => str.trim().toLowerCase();
if (removedLastNewLine) {
pos -= 1;
}
/**
* Truncates given text
*
* @param {String} string
* @param {Number} maxLength
* @returns {String}
*/
export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
return textArea.setSelectionRange(pos, pos);
}
};
gl.text.updateText = function(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = this.selectedText(text, textArea);
$textArea.focus();
return this.insertText(textArea, text, tag, blockTag, selected, wrap);
};
gl.text.init = function(form) {
var self;
self = this;
return $('.js-md', form).off('click').on('click', function() {
var $this;
$this = $(this);
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
gl.text.removeListeners = function(form) {
return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
};
gl.text.pluralize = function(str, count) {
return str + (count > 1 || count === 0 ? 's' : '');
};
gl.text.truncate = function(string, maxLength) {
return string.substr(0, (maxLength - 3)) + '...';
};
gl.text.dasherize = function(str) {
return str.replace(/[_\s]+/g, '-');
};
gl.text.slugify = function(str) {
return str.trim().toLowerCase().latinise();
};
......@@ -30,7 +30,6 @@ import './commit/image_file';
import { handleLocationHash } from './lib/utils/common_utils';
import './lib/utils/datetime_utility';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
import './lib/utils/url_utility';
// behaviors
......
......@@ -5,6 +5,7 @@ import 'vendor/jquery.waitforimages';
import TaskList from './task_list';
import './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
import { addDelimiter } from './lib/utils/text_utility';
(function() {
this.MergeRequest = (function() {
......@@ -124,7 +125,7 @@ import IssuablesHelper from './helpers/issuables_helper';
const $el = $('.nav-links .js-merge-counter');
const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0);
$el.text(gl.text.addDelimiter(count));
$el.text(addDelimiter(count));
};
MergeRequest.prototype.hideCloseButton = function() {
......
......@@ -357,7 +357,8 @@
@click="handleSave(true)"
v-if="canUpdateIssue"
:class="actionButtonClassNames"
class="btn btn-comment btn-comment-and-close">
:disabled="isSubmitting"
class="btn btn-comment btn-comment-and-close js-action-button">
{{issueActionButtonTitle}}
</button>
<button
......
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
import icon from '../../../vue_shared/components/icon.vue';
import { dasherize } from '../../../lib/utils/text_utility';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
......@@ -39,7 +39,7 @@
computed: {
cssClass() {
const actionIconDash = gl.text.dasherize(this.actionIcon);
const actionIconDash = dasherize(this.actionIcon);
return `${actionIconDash} js-icon-${actionIconDash}`;
},
},
......
......@@ -3,7 +3,7 @@ import flash from '../../flash';
import service from '../services';
import * as types from './mutation_types';
export const redirectToUrl = url => gl.utils.visitUrl(url);
export const redirectToUrl = (_, url) => gl.utils.visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
......@@ -84,7 +84,7 @@ export const commitChanges = ({ commit, state, dispatch, getters }, { payload, n
flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice');
if (newMr) {
redirectToUrl(`${state.endpoints.newMergeRequestUrl}${branch}`);
dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`);
} else {
commit(types.SET_COMMIT_REF, data.id);
......
......@@ -3,16 +3,16 @@ import * as types from '../mutation_types';
import { pushState } from '../utils';
// eslint-disable-next-line import/prefer-default-export
export const createNewBranch = ({ rootState, commit }, branch) => service.createBranch(
rootState.project.id,
export const createNewBranch = ({ state, commit }, branch) => service.createBranch(
state.project.id,
{
branch,
ref: rootState.currentBranch,
ref: state.currentBranch,
},
).then(res => res.json())
.then((data) => {
const branchName = data.name;
const url = location.href.replace(rootState.currentBranch, branchName);
const url = location.href.replace(state.currentBranch, branchName);
pushState(url);
......
import tooltip from '../../vue_shared/directives/tooltip';
import '../../lib/utils/text_utility';
import { pluralize } from '../../lib/utils/text_utility';
export default {
name: 'MRWidgetHeader',
......@@ -14,7 +14,7 @@ export default {
return this.mr.divergedCommitsCount > 0;
},
commitsText() {
return gl.text.pluralize('commit', this.mr.divergedCommitsCount);
return pluralize('commit', this.mr.divergedCommitsCount);
},
branchNameClipboardData() {
// This supports code in app/assets/javascripts/copy_to_clipboard.js that
......
import bp from './breakpoints';
import { slugify } from './lib/utils/text_utility';
export default class Wikis {
constructor() {
......@@ -23,7 +24,7 @@ export default class Wikis {
if (!this.newWikiForm) return;
const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
const slug = gl.text.slugify(slugInput.value);
const slug = slugify(slugInput.value);
if (slug.length > 0) {
const wikisPath = slugInput.getAttribute('data-wikis-path');
......
......@@ -401,10 +401,13 @@
.breadcrumbs-list {
display: -webkit-flex;
display: flex;
flex-wrap: wrap;
margin-bottom: 0;
line-height: 16px;
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
> li {
display: flex;
align-items: center;
......@@ -412,24 +415,35 @@
padding: 2px 0;
&:not(:last-child) {
margin-right: 20px;
padding-right: 20px;
&:not(.dropdown) {
overflow: hidden;
}
}
> a {
font-size: 12px;
color: currentColor;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
}
}
.breadcrumb-item-text {
@include str-truncated(128px);
text-decoration: inherit;
@media (max-width: $screen-xs-max) {
@include str-truncated(128px);
}
}
.breadcrumbs-list-angle {
position: absolute;
right: -12px;
right: 7px;
top: 50%;
color: $gl-text-color-tertiary;
transform: translateY(-50%);
......
......@@ -274,3 +274,22 @@
}
}
}
.modal-doorkeepr-auth,
.doorkeeper-app-form {
.scope-description {
color: $theme-gray-700;
}
}
.modal-doorkeepr-auth {
.modal-body {
padding: $gl-padding;
}
}
.doorkeeper-app-form {
.scope-description {
margin: 0 0 5px 17px;
}
}
......@@ -76,7 +76,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
def redirect_out_of_range(todos)
total_pages =
if todo_params.except(:sort, :page).empty?
(current_user.todos_pending_count / todos.limit_value).ceil
(current_user.todos_pending_count.to_f / todos.limit_value).ceil
else
todos.total_pages
end
......
......@@ -23,10 +23,17 @@ module IconsHelper
render "shared/icons/#{icon_name}.svg", size: size
end
def sprite_icon_path
# SVG Sprites currently don't work across domains, so in the case of a CDN
# we have to set the current path deliberately to prevent addition of asset_host
sprite_base_url = Gitlab.config.gitlab.url if ActionController::Base.asset_host
ActionController::Base.helpers.image_path('icons.svg', host: sprite_base_url)
end
def sprite_icon(icon_name, size: nil, css_class: nil)
css_classes = size ? "s#{size}" : ""
css_classes << " #{css_class}" unless css_class.blank?
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{image_path('icons.svg')}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes)
end
def audit_icon(names, options = {})
......
......@@ -4,15 +4,26 @@ module Avatarable
def avatar_path(only_path: true)
return unless self[:avatar].present?
# If only_path is true then use the relative path of avatar.
# Otherwise use full path (including host).
asset_host = ActionController::Base.asset_host
gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url
use_asset_host = asset_host.present?
# If asset_host is set then it is expected that assets are handled by a standalone host.
# That means we do not want to get GitLab's relative_url_root option anymore.
host = (asset_host.present? && (!respond_to?(:public?) || public?)) ? asset_host : gitlab_host
# Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host.
# If an asset host is configured, we need to return the fully qualified URL
# instead of only the avatar path, so that Rails doesn't prefix it with the asset host.
if use_asset_host && respond_to?(:public?) && !public?
use_asset_host = false
only_path = false
end
[host, avatar.url].join
url_base = ""
if use_asset_host
url_base << asset_host unless only_path
else
url_base << gitlab_config.base_url unless only_path
url_base << gitlab_config.relative_url_root
end
url_base + avatar.url
end
end
......@@ -890,7 +890,19 @@ class MergeRequest < ActiveRecord::Base
#
def all_commit_shas
if persisted?
column_shas = MergeRequestDiffCommit.where(merge_request_diff: merge_request_diffs).limit(10_000).pluck('sha')
# MySQL doesn't support LIMIT in a subquery.
diffs_relation =
if Gitlab::Database.postgresql?
merge_request_diffs.order(id: :desc).limit(100)
else
merge_request_diffs
end
column_shas = MergeRequestDiffCommit
.where(merge_request_diff: diffs_relation)
.limit(10_000)
.pluck('sha')
serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
(column_shas + serialised_shas).uniq
......
......@@ -1124,6 +1124,10 @@ class Repository
blob_data_at(sha, path)
end
def fetch_ref(source_repository, source_ref:, target_ref:)
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
private
# TODO Generice finder, later split this on finders by Ref or Oid
......
......@@ -3,7 +3,6 @@ class IssueEntity < IssuableEntity
expose :state
expose :deleted_at
expose :branch_name
expose :confidential
expose :discussion_locked
expose :assignees, using: API::Entities::UserBasic
......
= form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f|
= form_for application, url: doorkeeper_submit_path(application), html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
= form_errors(application)
.form-group
......
- auth_app_owner = @pre_auth.client.application.owner
%main{ :role => "main" }
.modal-no-backdrop
.modal-no-backdrop.modal-doorkeepr-auth
.modal-content
.modal-header
%h3.page-title
......@@ -16,14 +18,21 @@
%strong= @pre_auth.client.name
will allow them to interact with GitLab as an admin as well. Proceed with caution.
%p
You are about to authorize
An application called
= link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
to use your account.
- if @pre_auth.scopes
is requesting access to your GitLab account. This application was created by
= succeed "." do
= link_to auth_app_owner.name, user_path(auth_app_owner)
Please note that this application is not provided by GitLab and you should verify its authenticity before
allowing access.
- if @pre_auth.scopes
%p
This application will be able to:
%ul
- @pre_auth.scopes.each do |scope|
%li= t scope, scope: [:doorkeeper, :scopes]
%li
%strong= t scope, scope: [:doorkeeper, :scopes]
.scope-description= t scope, scope: [:doorkeeper, :scope_desc]
.form-actions.text-right
= form_tag oauth_authorization_path, method: :delete, class: 'inline' do
= hidden_field_tag :client_id, @pre_auth.client.uid
......
......@@ -7,3 +7,4 @@
= check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}"
= label_tag ("#{prefix}_scopes_#{scope}"), scope
%span= t(scope, scope: [:doorkeeper, :scopes])
.scope-description= t scope, scope: [:doorkeeper, :scope_desc]
---
title: Enables scroll to bottom once user has scrolled back to bottom in job log
merge_request:
author:
type: fixed
---
title: Fix acceptance of username for Mattermost service update
merge_request: 15275
author:
type: fixed
---
title: Adds typescript support
title: Clean up schema of the "issues" table
merge_request:
author:
type: added
type: other
---
title: Always return full avatar URL for private/internal groups/projects when asset
host is set
merge_request:
author:
type: fixed
---
title: Ensure merge requests with lots of version don't time out when searching for
pipelines
merge_request:
author:
type: performance
---
title: Fix access to the final page of todos
merge_request:
author:
type: fixed
---
title: Export text utils functions as es6 module and add tests
merge_request:
author:
type: other
......@@ -62,7 +62,15 @@ en:
read_user: Read the authenticated user's personal information
openid: Authenticate using OpenID Connect
sudo: Perform API actions as any user in the system (if the authenticated user is an admin)
scope_desc:
api:
Full access to GitLab as the user, including read/write on all their groups and projects
read_user:
Read-only access to the user's profile information, like username, public email and full name
openid:
The ability to authenticate using GitLab, and read-only access to the user's profile information
sudo:
Access to the Sudo feature, to perform API actions as any user in the system (only available for admins)
flash:
applications:
create:
......
......@@ -117,10 +117,6 @@ var config = {
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.ts$/,
loader: 'ts-loader',
},
{
test: /\.svg$/,
loader: 'raw-loader',
......@@ -269,7 +265,7 @@ var config = {
],
resolve: {
extensions: ['.js', '.ts'],
extensions: ['.js'],
alias: {
'ee': path.join(ROOT_PATH, 'ee/app/assets/javascripts'),
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
......
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesConfidentialNotNull < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
class Issue < ActiveRecord::Base
self.table_name = 'issues'
end
def up
Issue.where('confidential IS NULL').update_all(confidential: false)
change_column_null :issues, :confidential, false
end
def down
# There's no way / point to revert this.
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class IssuesMilestoneIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_milestones
where('NOT EXISTS (SELECT true FROM milestones WHERE milestones.id = issues.milestone_id)')
end
end
def up
Issue.with_orphaned_milestones.each_batch(of: 100) do |batch|
batch.update_all(milestone_id: nil)
end
add_concurrent_foreign_key(
:issues,
:milestones,
column: :milestone_id,
on_delete: :nullify
)
end
def down
remove_foreign_key_without_error(:issues, column: :milestone_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 IssuesUpdatedByIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_updaters
where('NOT EXISTS (SELECT true FROM users WHERE users.id = issues.updated_by_id)')
.where('updated_by_id IS NOT NULL')
end
end
def up
Issue.with_orphaned_updaters.each_batch(of: 100) do |batch|
batch.update_all(updated_by_id: nil)
end
# This index is only used for foreign keys, and those in turn will always
# specify a value. As such we can add a WHERE condition to make the index
# smaller.
add_concurrent_index(:issues, :updated_by_id, where: 'updated_by_id IS NOT NULL')
add_concurrent_foreign_key(
:issues,
:users,
column: :updated_by_id,
on_delete: :nullify
)
end
def down
remove_foreign_key_without_error(:issues, column: :updated_by_id)
remove_concurrent_index(:issues, :updated_by_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 IssuesMovedToIdForeignKey < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
class Issue < ActiveRecord::Base
include EachBatch
self.table_name = 'issues'
def self.with_orphaned_moved_to_issues
where('NOT EXISTS (SELECT true FROM issues WHERE issues.id = issues.moved_to_id)')
.where('moved_to_id IS NOT NULL')
end
end
def up
Issue.with_orphaned_moved_to_issues.each_batch(of: 100) do |batch|
batch.update_all(moved_to_id: nil)
end
add_concurrent_foreign_key(
:issues,
:issues,
column: :moved_to_id,
on_delete: :nullify
)
# We're using a partial index here so we only index the data we actually
# care about.
add_concurrent_index(:issues, :moved_to_id, where: 'moved_to_id IS NOT NULL')
end
def down
remove_foreign_key_without_error(:issues, column: :moved_to_id)
remove_concurrent_index(:issues, :moved_to_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 RemoveIssuesBranchName < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
def change
remove_column :issues, :branch_name, :string
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class TurnIssuesDueDateIndexToPartialIndex < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
NEW_INDEX_NAME = 'idx_issues_on_project_id_and_due_date_and_id_and_state_partial'
OLD_INDEX_NAME = 'index_issues_on_project_id_and_due_date_and_id_and_state'
disable_ddl_transaction!
def up
add_concurrent_index(
:issues,
[:project_id, :due_date, :id, :state],
where: 'due_date IS NOT NULL',
name: NEW_INDEX_NAME
)
# We set the column name to nil as otherwise Rails will ignore the custom
# index name and remove the wrong index.
remove_concurrent_index(:issues, nil, name: OLD_INDEX_NAME)
end
def down
add_concurrent_index(
:issues,
[:project_id, :due_date, :id, :state],
name: OLD_INDEX_NAME
)
remove_concurrent_index(:issues, nil, name: NEW_INDEX_NAME)
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddTimezoneToIssuesClosedAt < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
disable_ddl_transaction!
def up
change_column_type_concurrently(:issues, :closed_at, :datetime_with_timezone)
end
def down
cleanup_concurrent_column_type_change(:issues, :closed_at)
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CleanupAddTimezoneToIssuesClosedAt < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
cleanup_concurrent_column_type_change(:issues, :closed_at)
end
# rubocop:disable Migration/Datetime
def down
change_column_type_concurrently(:issues, :closed_at, :datetime)
end
end
......@@ -11,7 +11,11 @@
#
# It's strongly recommended that you check this file into your version control system.
<<<<<<< HEAD
ActiveRecord::Schema.define(version: 20171107144726) do
=======
ActiveRecord::Schema.define(version: 20171106180641) do
>>>>>>> ce-com/master
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -1114,14 +1118,17 @@ ActiveRecord::Schema.define(version: 20171107144726) do
t.integer "project_id"
t.datetime "created_at"
t.datetime "updated_at"
t.string "branch_name"
t.text "description"
t.integer "milestone_id"
t.string "state"
t.integer "iid"
t.integer "updated_by_id"
<<<<<<< HEAD
t.integer "weight"
t.boolean "confidential", default: false
=======
t.boolean "confidential", default: false, null: false
>>>>>>> ce-com/master
t.datetime "deleted_at"
t.date "due_date"
t.integer "moved_to_id"
......@@ -1130,12 +1137,16 @@ ActiveRecord::Schema.define(version: 20171107144726) do
t.text "description_html"
t.integer "time_estimate"
t.integer "relative_position"
<<<<<<< HEAD
t.datetime "closed_at"
t.string "service_desk_reply_to"
=======
>>>>>>> ce-com/master
t.integer "cached_markdown_version"
t.datetime "last_edited_at"
t.integer "last_edited_by_id"
t.boolean "discussion_locked"
t.datetime_with_timezone "closed_at"
end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
......@@ -1144,13 +1155,15 @@ ActiveRecord::Schema.define(version: 20171107144726) do
add_index "issues", ["deleted_at"], name: "index_issues_on_deleted_at", using: :btree
add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree
add_index "issues", ["moved_to_id"], name: "index_issues_on_moved_to_id", where: "(moved_to_id IS NOT NULL)", using: :btree
add_index "issues", ["project_id", "created_at", "id", "state"], name: "index_issues_on_project_id_and_created_at_and_id_and_state", using: :btree
add_index "issues", ["project_id", "due_date", "id", "state"], name: "index_issues_on_project_id_and_due_date_and_id_and_state", using: :btree
add_index "issues", ["project_id", "due_date", "id", "state"], name: "idx_issues_on_project_id_and_due_date_and_id_and_state_partial", where: "(due_date IS NOT NULL)", using: :btree
add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree
add_index "issues", ["project_id", "updated_at", "id", "state"], name: "index_issues_on_project_id_and_updated_at_and_id_and_state", using: :btree
add_index "issues", ["relative_position"], name: "index_issues_on_relative_position", using: :btree
add_index "issues", ["state"], name: "index_issues_on_state", using: :btree
add_index "issues", ["title"], name: "index_issues_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
add_index "issues", ["updated_by_id"], name: "index_issues_on_updated_by_id", where: "(updated_by_id IS NOT NULL)", using: :btree
create_table "keys", force: :cascade do |t|
t.integer "user_id"
......@@ -2434,8 +2447,11 @@ ActiveRecord::Schema.define(version: 20171107144726) do
add_foreign_key "issue_links", "issues", column: "source_id", name: "fk_c900194ff2", on_delete: :cascade
add_foreign_key "issue_links", "issues", column: "target_id", name: "fk_e71bb44f1f", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
add_foreign_key "issues", "issues", column: "moved_to_id", name: "fk_a194299be1", on_delete: :nullify
add_foreign_key "issues", "milestones", name: "fk_96b1dd429c", on_delete: :nullify
add_foreign_key "issues", "projects", name: "fk_899c8f3231", on_delete: :cascade
add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify
add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade
......
......@@ -490,6 +490,41 @@ Remove all previously JIRA settings from a project.
DELETE /projects/:id/services/jira
```
## Kubernetes
Kubernetes / Openshift integration
### Create/Edit Kubernetes service
Set Kubernetes service for a project.
```
PUT /projects/:id/services/kubernetes
```
Parameters:
- `namespace` (**required**) - The Kubernetes namespace to use
- `api_url` (**required**) - The URL to the Kubernetes cluster API, e.g., https://kubernetes.example.com
- `token` (**required**) - The service token to authenticate against the Kubernetes cluster with
- `ca_pem` (optional) - A custom certificate authority bundle to verify the Kubernetes cluster with (PEM format)
### Delete Kubernetes service
Delete Kubernetes service for a project.
```
DELETE /projects/:id/services/kubernetes
```
### Get Kubernetes service settings
Get Kubernetes service settings for a project.
```
GET /projects/:id/services/kubernetes
```
## Slack slash commands
Ability to receive slash commands from a Slack chat instance.
......@@ -572,7 +607,7 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `token` | string | yes | The Mattermost token |
| `username` | string | no | The username to use to post the message |
### Delete Mattermost slash command service
......
......@@ -299,9 +299,9 @@ sudo usermod -aG redis git
### Clone the Source
# Clone GitLab repository
sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-1-stable gitlab
sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-2-stable gitlab
**Note:** You can change `10-1-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
**Note:** You can change `10-2-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
### Configure It
......
This diff is collapsed.
......@@ -21,7 +21,7 @@ want to add.
---
Select the user and the [permission level](../../user/permissions.md)
Select the user and the [permission level](../../permissions.md)
that you'd like to give the user. Note that you can select more than one user.
![Give user permissions](img/add_user_give_permissions.png)
......
......@@ -521,6 +521,12 @@ module API
name: :webhook,
type: String,
desc: 'The Mattermost webhook. e.g. http://mattermost_host/hooks/...'
},
{
required: false,
name: :username,
type: String,
desc: 'The username to use to post the message'
}
],
'teamcity' => [
......
......@@ -72,7 +72,7 @@ module Gitlab
# Whenever `start_branch_name` is passed, if `branch_name` doesn't exist,
# it would be created from `start_branch_name`.
# If `start_project` is passed, and the branch doesn't exist,
# If `start_repository` is passed, and the branch doesn't exist,
# it would try to find the commits from it instead of current repository.
def with_branch(
branch_name,
......@@ -80,15 +80,13 @@ module Gitlab
start_repository: repository,
&block)
# Refactoring aid
unless start_repository.is_a?(Gitlab::Git::Repository)
raise "expected a Gitlab::Git::Repository, got #{start_repository}"
end
Gitlab::Git.check_namespace!(start_repository)
start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository)
start_branch_name = nil if start_repository.empty_repo?
if start_branch_name && !start_repository.branch_exists?(start_branch_name)
raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.full_path}"
raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.relative_path}"
end
update_branch_with_hooks(branch_name) do
......
module Gitlab
module Git
#
# When a Gitaly call involves two repositories instead of one we cannot
# assume that both repositories are on the same Gitaly server. In this
# case we need to make a distinction between the repository that the
# call is being made on (a Repository instance), and the "other"
# repository (a RemoteRepository instance). This is the reason why we
# have the RemoteRepository class in Gitlab::Git.
#
# When you make changes, be aware that gitaly-ruby sub-classes this
# class.
#
class RemoteRepository
attr_reader :path, :relative_path, :gitaly_repository
def initialize(repository)
@relative_path = repository.relative_path
@gitaly_repository = repository.gitaly_repository
# These instance variables will not be available in gitaly-ruby, where
# we have no disk access to this repository.
@repository = repository
@path = repository.path
end
def empty_repo?
# We will override this implementation in gitaly-ruby because we cannot
# use '@repository' there.
@repository.empty_repo?
end
def commit_id(revision)
# We will override this implementation in gitaly-ruby because we cannot
# use '@repository' there.
@repository.commit(revision)&.sha
end
def branch_exists?(name)
# We will override this implementation in gitaly-ruby because we cannot
# use '@repository' there.
@repository.branch_exists?(name)
end
# Compares self to a Gitlab::Git::Repository. This implementation uses
# 'self.gitaly_repository' so that it will also work in the
# GitalyRemoteRepository subclass defined in gitaly-ruby.
def same_repository?(other_repository)
gitaly_repository.storage_name == other_repository.storage &&
gitaly_repository.relative_path == other_repository.relative_path
end
def fetch_env
gitaly_ssh = File.absolute_path(File.join(Gitlab.config.gitaly.client_path, 'gitaly-ssh'))
gitaly_address = gitaly_client.address(storage)
gitaly_token = gitaly_client.token(storage)
request = Gitaly::SSHUploadPackRequest.new(repository: gitaly_repository)
env = {
'GITALY_ADDRESS' => gitaly_address,
'GITALY_PAYLOAD' => request.to_json,
'GITALY_WD' => Dir.pwd,
'GIT_SSH_COMMAND' => "#{gitaly_ssh} upload-pack"
}
env['GITALY_TOKEN'] = gitaly_token if gitaly_token.present?
env
end
private
# Must return an object that responds to 'address' and 'storage'.
def gitaly_client
Gitlab::GitalyClient
end
def storage
gitaly_repository.storage_name
end
end
end
end
......@@ -58,7 +58,7 @@ module Gitlab
# Rugged repo object
attr_reader :rugged
attr_reader :storage, :gl_repository, :relative_path, :gitaly_resolver
attr_reader :storage, :gl_repository, :relative_path
# This initializer method is only used on the client side (gitlab-ce).
# Gitaly-ruby uses a different initializer.
......@@ -66,7 +66,6 @@ module Gitlab
@storage = storage
@relative_path = relative_path
@gl_repository = gl_repository
@gitaly_resolver = Gitlab::GitalyClient
storage_path = Gitlab.config.repositories.storages[@storage]['path']
@path = File.join(storage_path, @relative_path)
......@@ -105,7 +104,7 @@ module Gitlab
end
def exists?
Gitlab::GitalyClient.migrate(:repository_exists) do |enabled|
Gitlab::GitalyClient.migrate(:repository_exists, status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT) do |enabled|
if enabled
gitaly_repository_client.exists?
else
......@@ -1014,23 +1013,22 @@ module Gitlab
def with_repo_branch_commit(start_repository, start_branch_name)
Gitlab::Git.check_namespace!(start_repository)
start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository)
return yield nil if start_repository.empty_repo?
if start_repository == self
if start_repository.same_repository?(self)
yield commit(start_branch_name)
else
start_commit = start_repository.commit(start_branch_name)
start_commit_id = start_repository.commit_id(start_branch_name)
return yield nil unless start_commit
return yield nil unless start_commit_id
sha = start_commit.sha
if branch_commit = commit(sha)
if branch_commit = commit(start_commit_id)
yield branch_commit
else
with_repo_tmp_commit(
start_repository, start_branch_name, sha) do |tmp_commit|
start_repository, start_branch_name, start_commit_id) do |tmp_commit|
yield tmp_commit
end
end
......@@ -1087,6 +1085,9 @@ module Gitlab
end
def fetch_ref(source_repository, source_ref:, target_ref:)
Gitlab::Git.check_namespace!(source_repository)
source_repository = RemoteRepository.new(source_repository) unless source_repository.is_a?(RemoteRepository)
message, status = GitalyClient.migrate(:fetch_ref) do |is_enabled|
if is_enabled
gitaly_fetch_ref(source_repository, source_ref: source_ref, target_ref: target_ref)
......@@ -1620,22 +1621,9 @@ module Gitlab
end
def gitaly_fetch_ref(source_repository, source_ref:, target_ref:)
gitaly_ssh = File.absolute_path(File.join(Gitlab.config.gitaly.client_path, 'gitaly-ssh'))
gitaly_address = gitaly_resolver.address(source_repository.storage)
gitaly_token = gitaly_resolver.token(source_repository.storage)
request = Gitaly::SSHUploadPackRequest.new(repository: source_repository.gitaly_repository)
env = {
'GITALY_ADDRESS' => gitaly_address,
'GITALY_PAYLOAD' => request.to_json,
'GITALY_WD' => Dir.pwd,
'GIT_SSH_COMMAND' => "#{gitaly_ssh} upload-pack"
}
env['GITALY_TOKEN'] = gitaly_token if gitaly_token.present?
args = %W(fetch --no-tags -f ssh://gitaly/internal.git #{source_ref}:#{target_ref})
run_git(args, env: env)
run_git(args, env: source_repository.fetch_env)
end
def gitaly_ff_merge(user, source_sha, target_branch)
......
......@@ -20,7 +20,7 @@ module Gitlab
gon.gitlab_url = Gitlab.config.gitlab.url
gon.revision = Gitlab::REVISION
gon.gitlab_logo = ActionController::Base.helpers.asset_path('gitlab_logo.png')
gon.sprite_icons = ActionController::Base.helpers.asset_path('icons.svg')
gon.sprite_icons = IconsHelper.sprite_icon_path
if current_user
gon.current_user_id = current_user.id
......
......@@ -4,7 +4,6 @@ module Gitlab
SAFE_HOOK_ATTRIBUTES = %i[
assignee_id
author_id
branch_name
closed_at
confidential
created_at
......
......@@ -62,6 +62,7 @@ module QA
module Main
autoload :Entry, 'qa/page/main/entry'
autoload :Login, 'qa/page/main/login'
autoload :Menu, 'qa/page/main/menu'
end
......
......@@ -23,7 +23,7 @@ module QA
def password=(pass)
@password = pass
@uri.password = pass
@uri.password = CGI.escape(pass)
end
def use_default_credentials
......
......@@ -5,7 +5,7 @@ module QA
include Scenario::Actable
def refresh
visit current_path
visit current_url
end
end
end
......
......@@ -2,9 +2,14 @@ module QA
module Page
module Main
class Entry < Page::Base
def initialize
visit('/')
def visit_login_page
visit("#{Runtime::Scenario.gitlab_address}/users/sign_in")
wait_for_instance_to_be_ready
end
private
def wait_for_instance_to_be_ready
# This resolves cold boot / background tasks problems
#
start = Time.now
......@@ -14,18 +19,6 @@ module QA
refresh
end
end
def sign_in_using_credentials
if page.has_content?('Change your password')
fill_in :user_password, with: Runtime::User.password
fill_in :user_password_confirmation, with: Runtime::User.password
click_button 'Change your password'
end
fill_in :user_login, with: Runtime::User.name
fill_in :user_password, with: Runtime::User.password
click_button 'Sign in'
end
end
end
end
......
module QA
module Page
module Main
class Login < Page::Base
def sign_in_using_credentials
if page.has_content?('Change your password')
fill_in :user_password, with: Runtime::User.password
fill_in :user_password_confirmation, with: Runtime::User.password
click_button 'Change your password'
end
fill_in :user_login, with: Runtime::User.name
fill_in :user_password, with: Runtime::User.password
click_button 'Sign in'
end
end
end
end
end
......@@ -7,18 +7,8 @@ module QA
class Entrypoint < Template
include Bootable
def self.tags(*tags)
@tags = tags
end
def self.get_tags
@tags
end
def perform(address, *files)
Specs::Config.perform do |specs|
specs.address = address
end
Runtime::Scenario.define(:gitlab_address, address)
##
# Perform before hooks, which are different for CE and EE
......@@ -26,13 +16,19 @@ module QA
Runtime::Release.perform_before_hooks
Specs::Runner.perform do |specs|
specs.rspec(
tty: true,
tags: self.class.get_tags,
files: files.any? ? files : 'qa/specs/features'
)
specs.tty = true
specs.tags = self.class.get_tags
specs.files = files.any? ? files : 'qa/specs/features'
end
end
def self.tags(*tags)
@tags = tags
end
def self.get_tags
@tags
end
end
end
end
......@@ -9,15 +9,7 @@ require 'selenium-webdriver'
module QA
module Specs
class Config < Scenario::Template
attr_writer :address
def initialize
@address = ENV['GITLAB_URL']
end
def perform
raise 'Please configure GitLab address!' unless @address
configure_rspec!
configure_capybara!
end
......@@ -56,7 +48,6 @@ module QA
end
Capybara.configure do |config|
config.app_host = @address
config.default_driver = :chrome
config.javascript_driver = :chrome
config.default_max_wait_time = 4
......
module QA
feature 'standard root login', :core do
scenario 'user logs in using credentials' do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
# TODO, since `Signed in successfully` message was removed
# this is the only way to tell if user is signed in correctly.
......
module QA
feature 'create a new group', :mattermost do
scenario 'creating a group with a mattermost team' do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
Page::Main::Menu.act { go_to_groups }
Page::Dashboard::Groups.perform do |page|
......
module QA
feature 'logging in to Mattermost', :mattermost do
scenario 'can use gitlab oauth' do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
Page::Mattermost::Login.act { sign_in_using_oauth }
Page::Mattermost::Main.perform do |page|
......
module QA
feature 'create a new project', :core do
scenario 'user creates a new project' do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
Scenario::Gitlab::Project::Create.perform do |project|
project.name = 'awesome-project'
......
......@@ -9,7 +9,8 @@ module QA
end
before do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
Scenario::Gitlab::Project::Create.perform do |scenario|
scenario.name = 'project-with-code'
......
......@@ -2,7 +2,8 @@ module QA
feature 'push code to repository', :core do
context 'with regular account over http' do
scenario 'user pushes code to the repository' do
Page::Main::Entry.act { sign_in_using_credentials }
Page::Main::Entry.act { visit_login_page }
Page::Main::Login.act { sign_in_using_credentials }
Scenario::Gitlab::Project::Create.perform do |scenario|
scenario.name = 'project_with_code'
......
......@@ -2,16 +2,22 @@ require 'rspec/core'
module QA
module Specs
class Runner
include Scenario::Actable
class Runner < Scenario::Template
attr_accessor :tty, :tags, :files
def rspec(tty: false, tags: [], files: ['qa/specs/features'])
def initialize
@tty = false
@tags = []
@files = ['qa/specs/features']
end
def perform
args = []
args << '--tty' if tty
tags.to_a.each do |tag|
args << ['-t', tag.to_s]
end
args << files
args.push('--tty') if tty
tags.to_a.each { |tag| args.push(['-t', tag.to_s]) }
args.push(files)
Specs::Config.perform
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
abort if status.nonzero?
......
......@@ -6,31 +6,30 @@ describe QA::Scenario::Entrypoint do
end
context '#perform' do
let(:config) { spy('Specs::Config') }
let(:arguments) { spy('Runtime::Scenario') }
let(:release) { spy('Runtime::Release') }
let(:runner) { spy('Specs::Runner') }
before do
allow(config).to receive(:perform) { |&block| block.call config }
allow(runner).to receive(:perform) { |&block| block.call runner }
stub_const('QA::Specs::Config', config)
stub_const('QA::Runtime::Release', release)
stub_const('QA::Runtime::Scenario', arguments)
stub_const('QA::Specs::Runner', runner)
allow(runner).to receive(:perform).and_yield(runner)
end
it 'should set address' do
it 'sets an address of the subject' do
subject.perform("hello")
expect(config).to have_received(:address=).with("hello")
expect(arguments).to have_received(:define)
.with(:gitlab_address, "hello")
end
context 'no paths' do
it 'should call runner with default arguments' do
subject.perform("test")
expect(runner).to have_received(:rspec)
.with(hash_including(files: 'qa/specs/features'))
expect(runner).to have_received(:files=).with('qa/specs/features')
end
end
......@@ -38,8 +37,7 @@ describe QA::Scenario::Entrypoint do
it 'should call runner with paths' do
subject.perform('test', 'path1', 'path2')
expect(runner).to have_received(:rspec)
.with(hash_including(files: %w(path1 path2)))
expect(runner).to have_received(:files=).with(%w[path1 path2])
end
end
end
......
......@@ -27,9 +27,12 @@ fi
cp config/database.yml.$GITLAB_DATABASE config/database.yml
<<<<<<< HEAD
# EE-only
cp config/database_geo.yml.$GITLAB_DATABASE config/database_geo.yml
=======
>>>>>>> ce-com/master
# Set user to a non-superuser to ensure we test permissions
sed -i 's/username: root/username: gitlab/g' config/database.yml
......
......@@ -44,11 +44,11 @@ describe Dashboard::TodosController do
context 'when using pagination' do
let(:last_page) { user.todos.page.total_pages }
let!(:issues) { create_list(:issue, 2, project: project, assignees: [user]) }
let!(:issues) { create_list(:issue, 3, project: project, assignees: [user]) }
before do
issues.each { |issue| todo_service.new_issue(issue, user) }
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
allow(Kaminari.config).to receive(:default_per_page).and_return(2)
end
it 'redirects to last_page if page number is larger than number of pages' do
......
require 'rails_helper'
feature 'Issue Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) }
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) }
context 'when user displays the issue' do
before do
......@@ -27,6 +27,7 @@ feature 'Issue Detail', :js do
click_link 'Edit'
fill_in 'issuable-title', with: 'issue title'
click_button 'Save'
wait_for_requests
Users::DestroyService.new(user).execute(user)
......
......@@ -89,6 +89,7 @@ feature 'Create New Merge Request', :js do
expect(target_items.count).to be > 1
end
<<<<<<< HEAD
context 'when approvals are disabled for the target project' do
it 'does not show approval settings' do
visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'feature_conflict' })
......@@ -118,6 +119,8 @@ feature 'Create New Merge Request', :js do
end
end
=======
>>>>>>> ce-com/master
context 'when target project cannot be viewed by the current user' do
it 'does not leak the private project name & namespace' do
private_project = create(:project, :private, :repository)
......
......@@ -79,22 +79,6 @@ feature 'Merge Request filtering by Labels', :js do
end
end
context 'clear button' do
before do
input_filtered_search('label:~bug')
end
it 'allows user to remove filtered labels' do
first('.clear-search').click
filtered_search.send_keys(:enter)
expect(page).to have_issuable_counts(open: 3, closed: 0, all: 3)
expect(page).to have_content "Bugfix2"
expect(page).to have_content "Feature1"
expect(page).to have_content "Bugfix1"
end
end
context 'filter dropdown' do
it 'filters by label name' do
init_label_search
......
......@@ -4,8 +4,6 @@ require 'spec_helper'
describe ApplicationHelper do
include UploadHelpers
let(:gitlab_host) { "http://#{Gitlab.config.gitlab.host}" }
describe 'current_controller?' do
it 'returns true when controller matches argument' do
stub_controller_name('foo')
......@@ -57,30 +55,11 @@ describe ApplicationHelper do
end
describe 'project_icon' do
let(:asset_host) { 'http://assets' }
it 'returns an url for the avatar' do
project = create(:project, :public, avatar: File.open(uploaded_image_temp_path))
avatar_url = "/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
avatar_url = "#{asset_host}/uploads/-/system/project/avatar/#{project.id}/banana_sample.gif"
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
it 'gives uploaded icon when present' do
project = create(:project)
allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true)
avatar_url = "#{gitlab_host}#{project_avatar_path(project)}"
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
.to eq "<img data-src=\"#{project.avatar.url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
......@@ -91,40 +70,7 @@ describe ApplicationHelper do
context 'when there is a matching user' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon(user.email).to_s)
.to eq("/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
context 'when an asset_host is set in the config' do
let(:asset_host) { 'http://assets' }
before do
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
end
it 'returns an absolute URL on that asset host' do
expect(helper.avatar_icon(user.email, only_path: false).to_s)
.to eq("#{asset_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
end
context 'when only_path is set to false' do
it 'returns an absolute URL for the avatar' do
expect(helper.avatar_icon(user.email, only_path: false).to_s)
.to eq("#{gitlab_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
end
context 'when the GitLab instance is at a relative URL' do
before do
stub_config_setting(relative_url_root: '/gitlab')
# Must be stubbed after the stub above, and separately
stub_config_setting(url: Settings.send(:build_gitlab_url))
end
it 'returns a relative URL with the correct prefix' do
expect(helper.avatar_icon(user.email).to_s)
.to eq("/gitlab/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
.to eq(user.avatar.url)
end
end
......@@ -138,18 +84,9 @@ describe ApplicationHelper do
end
describe 'using a user' do
context 'when only_path is true' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon(user, only_path: true).to_s)
.to eq("/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
end
context 'when only_path is false' do
it 'returns an absolute URL for the avatar' do
expect(helper.avatar_icon(user, only_path: false).to_s)
.to eq("#{gitlab_host}/uploads/-/system/user/avatar/#{user.id}/banana_sample.gif")
end
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon(user).to_s)
.to eq(user.avatar.url)
end
end
end
......
......@@ -3,8 +3,6 @@ require 'spec_helper'
describe GroupsHelper do
include ApplicationHelper
let(:asset_host) { 'http://assets' }
describe 'group_icon' do
avatar_file_path = File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')
......@@ -13,16 +11,8 @@ describe GroupsHelper do
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
avatar_url = "/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif"
expect(helper.group_icon(group).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
avatar_url = "#{asset_host}/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif"
expect(helper.group_icon(group).to_s)
.to eq "<img data-src=\"#{avatar_url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
.to eq "<img data-src=\"#{group.avatar.url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
......@@ -34,25 +24,7 @@ describe GroupsHelper do
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
expect(group_icon_url(group.path).to_s)
.to match("/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif")
end
it 'returns an CDN url for the avatar' do
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
group = create(:group)
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
expect(group_icon_url(group.path).to_s)
.to match("#{asset_host}/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif")
end
it 'returns an based url for the avatar if private' do
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
group = create(:group, :private)
group.avatar = fixture_file_upload(avatar_file_path)
group.save!
expect(group_icon_url(group.path).to_s)
.to match("/uploads/-/system/group/avatar/#{group.id}/banana_sample.gif")
.to match(group.avatar.url)
end
it 'gives default avatar_icon when no avatar is present' do
......
require 'spec_helper'
describe IconsHelper do
let(:icons_path) { ActionController::Base.helpers.image_path("icons.svg") }
describe 'icon' do
it 'returns aria-hidden by default' do
star = icon('star')
......@@ -16,22 +18,42 @@ describe IconsHelper do
end
end
describe 'sprite_icon_path' do
it 'returns relative path' do
expect(sprite_icon_path)
.to eq icons_path
end
context 'when an asset_host is set in the config it will return an absolute local URL' do
let(:asset_host) { 'http://assets' }
before do
allow(ActionController::Base).to receive(:asset_host).and_return(asset_host)
end
it 'returns an absolute URL on that asset host' do
expect(sprite_icon_path)
.to eq ActionController::Base.helpers.image_path("icons.svg", host: Gitlab.config.gitlab.url)
end
end
end
describe 'sprite_icon' do
icon_name = 'clock'
it 'returns svg icon html' do
expect(sprite_icon(icon_name).to_s)
.to eq "<svg><use xlink:href=\"/images/icons.svg##{icon_name}\"></use></svg>"
.to eq "<svg><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size classes' do
expect(sprite_icon(icon_name, size: 72).to_s)
.to eq "<svg class=\"s72\"><use xlink:href=\"/images/icons.svg##{icon_name}\"></use></svg>"
.to eq "<svg class=\"s72\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
it 'returns svg icon html + size classes + additional class' do
expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s)
.to eq "<svg class=\"s72 icon-danger\"><use xlink:href=\"/images/icons.svg##{icon_name}\"></use></svg>"
.to eq "<svg class=\"s72 icon-danger\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>"
end
end
......
......@@ -396,6 +396,25 @@ describe('Filtered Search Manager', () => {
});
});
describe('Clearing search', () => {
beforeEach(() => {
initializeManager();
});
it('Clicking the "x" clear button, clears the input', () => {
const inputValue = 'label:~bug ';
manager.filteredSearchInput.value = inputValue;
manager.filteredSearchInput.dispatchEvent(new Event('input'));
expect(gl.DropdownUtils.getSearchQuery()).toEqual(inputValue);
manager.clearSearchButton.click();
expect(manager.filteredSearchInput.value).toEqual('');
expect(gl.DropdownUtils.getSearchQuery()).toEqual('');
});
});
describe('toggleInputContainerFocus', () => {
beforeEach(() => {
initializeManager();
......
import textUtils from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
beforeAll(() => {
textArea = document.createElement('textarea');
document.querySelector('body').appendChild(textArea);
textArea.focus();
});
afterAll(() => {
textArea.parentNode.removeChild(textArea);
});
describe('without selection', () => {
it('inserts the tag on an empty line', () => {
const initialValue = '';
textArea.value = initialValue;
textArea.selectionStart = 0;
textArea.selectionEnd = 0;
textUtils.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
it('inserts the tag on a new line if the current one is not empty', () => {
const initialValue = 'some text';
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
textUtils.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}\n* `);
});
it('inserts the tag on the same line if the current line only contains spaces', () => {
const initialValue = ' ';
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
textUtils.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
it('inserts the tag on the same line if the current line only contains tabs', () => {
const initialValue = '\t\t\t';
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
textUtils.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
});
});
<<<<<<< HEAD
import * as textUtility from '~/lib/utils/text_utility';
=======
import * as textUtils from '~/lib/utils/text_utility';
>>>>>>> ce-com/master
describe('text_utility', () => {
describe('gl.text.getTextWidth', () => {
it('returns zero width when no text is passed', () => {
expect(gl.text.getTextWidth('')).toBe(0);
describe('addDelimiter', () => {
it('should add a delimiter to the given string', () => {
expect(textUtils.addDelimiter('1234')).toEqual('1,234');
expect(textUtils.addDelimiter('222222')).toEqual('222,222');
});
it('returns zero width when no text is passed and font is passed', () => {
expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0);
});
it('returns width when text is passed', () => {
expect(gl.text.getTextWidth('foo') > 0).toBe(true);
});
it('returns bigger width when font is larger', () => {
const largeFont = gl.text.getTextWidth('foo', '100px sans-serif');
const regular = gl.text.getTextWidth('foo', '10px sans-serif');
expect(largeFont > regular).toBe(true);
});
});
describe('gl.text.pluralize', () => {
it('returns pluralized', () => {
expect(gl.text.pluralize('test', 2)).toBe('tests');
});
it('returns pluralized when count is 0', () => {
expect(gl.text.pluralize('test', 0)).toBe('tests');
});
it('does not return pluralized', () => {
expect(gl.text.pluralize('test', 1)).toBe('test');
it('should not add a delimiter if string contains no numbers', () => {
expect(textUtils.addDelimiter('aaaa')).toEqual('aaaa');
});
});
describe('highCountTrim', () => {
it('returns 99+ for count >= 100', () => {
<<<<<<< HEAD
expect(textUtility.highCountTrim(105)).toBe('99+');
expect(textUtility.highCountTrim(100)).toBe('99+');
});
......@@ -49,67 +31,46 @@ describe('text_utility', () => {
describe('capitalizeFirstCharacter', () => {
it('returns string with first letter capitalized', () => {
expect(textUtility.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
=======
expect(textUtils.highCountTrim(105)).toBe('99+');
expect(textUtils.highCountTrim(100)).toBe('99+');
});
});
describe('gl.text.insertText', () => {
let textArea;
beforeAll(() => {
textArea = document.createElement('textarea');
document.querySelector('body').appendChild(textArea);
textArea.focus();
it('returns exact number for count < 100', () => {
expect(textUtils.highCountTrim(45)).toBe(45);
>>>>>>> ce-com/master
});
});
afterAll(() => {
textArea.parentNode.removeChild(textArea);
describe('humanize', () => {
it('should remove underscores and uppercase the first letter', () => {
expect(textUtils.humanize('foo_bar')).toEqual('Foo bar');
});
});
describe('without selection', () => {
it('inserts the tag on an empty line', () => {
const initialValue = '';
textArea.value = initialValue;
textArea.selectionStart = 0;
textArea.selectionEnd = 0;
gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
it('inserts the tag on a new line if the current one is not empty', () => {
const initialValue = 'some text';
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}\n* `);
});
it('inserts the tag on the same line if the current line only contains spaces', () => {
const initialValue = ' ';
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
describe('pluralize', () => {
it('should pluralize given string', () => {
expect(textUtils.pluralize('test', 2)).toBe('tests');
});
it('inserts the tag on the same line if the current line only contains tabs', () => {
const initialValue = '\t\t\t';
it('should pluralize when count is 0', () => {
expect(textUtils.pluralize('test', 0)).toBe('tests');
});
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
it('should not pluralize when count is 1', () => {
expect(textUtils.pluralize('test', 1)).toBe('test');
});
});
gl.text.insertText(textArea, textArea.value, '*', null, '', false);
describe('dasherize', () => {
it('should replace underscores with dashes', () => {
expect(textUtils.dasherize('foo_bar_foo')).toEqual('foo-bar-foo');
});
});
expect(textArea.value).toEqual(`${initialValue}* `);
});
describe('slugify', () => {
it('should remove accents and convert to lower case', () => {
expect(textUtils.slugify('João')).toEqual('joão');
});
});
});
......@@ -55,6 +55,25 @@ describe('issue_comment_form component', () => {
expect(vm.toggleIssueState).toHaveBeenCalled();
});
it('should disable action button whilst submitting', (done) => {
const saveNotePromise = Promise.resolve();
vm.note = 'hello world';
spyOn(vm, 'saveNote').and.returnValue(saveNotePromise);
spyOn(vm, 'stopPolling');
const actionButton = vm.$el.querySelector('.js-action-button');
vm.handleSave();
Vue.nextTick()
.then(() => expect(actionButton.disabled).toBeTruthy())
.then(saveNotePromise)
.then(Vue.nextTick)
.then(() => expect(actionButton.disabled).toBeFalsy())
.then(done)
.catch(done.fail);
});
});
describe('textarea', () => {
......
......@@ -12,9 +12,4 @@ export const file = (name = 'name', id = name, type = '') => decorateData({
url: 'url',
name,
path: name,
last_commit: {
id: '123',
message: 'test',
committed_date: new Date().toISOString(),
},
});
import store from '~/repo/stores';
import service from '~/repo/services';
import { resetStore } from '../../helpers';
describe('Multi-file store branch actions', () => {
afterEach(() => {
resetStore(store);
});
describe('createNewBranch', () => {
beforeEach(() => {
spyOn(service, 'createBranch').and.returnValue(Promise.resolve({
json: () => ({
name: 'testing',
}),
}));
spyOn(history, 'pushState');
store.state.project.id = 2;
store.state.currentBranch = 'testing';
});
it('creates new branch', (done) => {
store.dispatch('createNewBranch', 'master')
.then(() => {
expect(store.state.currentBranch).toBe('testing');
expect(service.createBranch).toHaveBeenCalledWith(2, {
branch: 'master',
ref: 'testing',
});
expect(history.pushState).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
});
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import * as getters from '~/repo/stores/getters';
import state from '~/repo/stores/state';
import { file } from '../helpers';
describe('Multi-file store getters', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('treeList', () => {
it('returns flat tree list', () => {
localState.tree.push(file('1'));
localState.tree[0].tree.push(file('2'));
localState.tree[0].tree[0].tree.push(file('3'));
const treeList = getters.treeList(localState);
expect(treeList.length).toBe(3);
expect(treeList[1].name).toBe(localState.tree[0].tree[0].name);
expect(treeList[2].name).toBe(localState.tree[0].tree[0].tree[0].name);
});
});
describe('changedFiles', () => {
it('returns a list of changed opened files', () => {
localState.openFiles.push(file());
localState.openFiles.push(file('changed'));
localState.openFiles[1].changed = true;
const changedFiles = getters.changedFiles(localState);
expect(changedFiles.length).toBe(1);
expect(changedFiles[0].name).toBe('changed');
});
});
describe('activeFile', () => {
it('returns the current active file', () => {
localState.openFiles.push(file());
localState.openFiles.push(file('active'));
localState.openFiles[1].active = true;
expect(getters.activeFile(localState).name).toBe('active');
});
it('returns undefined if no active files are found', () => {
localState.openFiles.push(file());
localState.openFiles.push(file('active'));
expect(getters.activeFile(localState)).toBeUndefined();
});
});
describe('activeFileExtension', () => {
it('returns the file extension for the current active file', () => {
localState.openFiles.push(file('active'));
localState.openFiles[0].active = true;
localState.openFiles[0].path = 'test.js';
expect(getters.activeFileExtension(localState)).toBe('.js');
localState.openFiles[0].path = 'test.es6.js';
expect(getters.activeFileExtension(localState)).toBe('.js');
});
});
describe('isCollapsed', () => {
it('returns true if state has open files', () => {
localState.openFiles.push(file());
expect(getters.isCollapsed(localState)).toBeTruthy();
});
it('returns false if state has no open files', () => {
expect(getters.isCollapsed(localState)).toBeFalsy();
});
});
describe('canEditFile', () => {
beforeEach(() => {
localState.onTopOfBranch = true;
localState.canCommit = true;
localState.openFiles.push(file());
localState.openFiles[0].active = true;
});
it('returns true if user can commit and has open files', () => {
expect(getters.canEditFile(localState)).toBeTruthy();
});
it('returns false if user can commit and has no open files', () => {
localState.openFiles = [];
expect(getters.canEditFile(localState)).toBeFalsy();
});
it('returns false if user can commit and active file is binary', () => {
localState.openFiles[0].binary = true;
expect(getters.canEditFile(localState)).toBeFalsy();
});
it('returns false if user cant commit', () => {
localState.canCommit = false;
expect(getters.canEditFile(localState)).toBeFalsy();
});
it('returns false if user can commit but on a branch', () => {
localState.onTopOfBranch = false;
expect(getters.canEditFile(localState)).toBeFalsy();
});
});
});
import mutations from '~/repo/stores/mutations/branch';
import state from '~/repo/stores/state';
describe('Multi-file store branch mutations', () => {
let localState;
beforeEach(() => {
localState = state();
});
describe('SET_CURRENT_BRANCH', () => {
it('sets currentBranch', () => {
mutations.SET_CURRENT_BRANCH(localState, 'master');
expect(localState.currentBranch).toBe('master');
});
});
});
import mutations from '~/repo/stores/mutations/file';
import state from '~/repo/stores/state';
import { file } from '../../helpers';
describe('Multi-file store file mutations', () => {
let localState;
let localFile;
beforeEach(() => {
localState = state();
localFile = file();
});
describe('SET_FILE_ACTIVE', () => {
it('sets the file active', () => {
mutations.SET_FILE_ACTIVE(localState, {
file: localFile,
active: true,
});
expect(localFile.active).toBeTruthy();
});
});
describe('TOGGLE_FILE_OPEN', () => {
beforeEach(() => {
mutations.TOGGLE_FILE_OPEN(localState, localFile);
});
it('adds into opened files', () => {
expect(localFile.opened).toBeTruthy();
expect(localState.openFiles.length).toBe(1);
});
it('removes from opened files', () => {
mutations.TOGGLE_FILE_OPEN(localState, localFile);
expect(localFile.opened).toBeFalsy();
expect(localState.openFiles.length).toBe(0);
});
});
describe('SET_FILE_DATA', () => {
it('sets extra file data', () => {
mutations.SET_FILE_DATA(localState, {
data: {
blame_path: 'blame',
commits_path: 'commits',
permalink: 'permalink',
raw_path: 'raw',
binary: true,
html: 'html',
render_error: 'render_error',
},
file: localFile,
});
expect(localFile.blamePath).toBe('blame');
expect(localFile.commitsPath).toBe('commits');
expect(localFile.permalink).toBe('permalink');
expect(localFile.rawPath).toBe('raw');
expect(localFile.binary).toBeTruthy();
expect(localFile.html).toBe('html');
expect(localFile.renderError).toBe('render_error');
});
});
describe('SET_FILE_RAW_DATA', () => {
it('sets raw data', () => {
mutations.SET_FILE_RAW_DATA(localState, {
file: localFile,
raw: 'testing',
});
expect(localFile.raw).toBe('testing');
});
});
describe('UPDATE_FILE_CONTENT', () => {
beforeEach(() => {
localFile.raw = 'test';
});
it('sets content', () => {
mutations.UPDATE_FILE_CONTENT(localState, {
file: localFile,
content: 'test',
});
expect(localFile.content).toBe('test');
});
it('sets changed if content does not match raw', () => {
mutations.UPDATE_FILE_CONTENT(localState, {
file: localFile,
content: 'testing',
});
expect(localFile.content).toBe('testing');
expect(localFile.changed).toBeTruthy();
});
});
describe('DISCARD_FILE_CHANGES', () => {
beforeEach(() => {
localFile.content = 'test';
localFile.changed = true;
});
it('resets content and changed', () => {
mutations.DISCARD_FILE_CHANGES(localState, localFile);
expect(localFile.content).toBe('');
expect(localFile.changed).toBeFalsy();
});
});
describe('CREATE_TMP_FILE', () => {
it('adds file into parent tree', () => {
const f = file();
mutations.CREATE_TMP_FILE(localState, {
file: f,
parent: localFile,
});
expect(localFile.tree.length).toBe(1);
expect(localFile.tree[0].name).toBe(f.name);
});
});
});
import mutations from '~/repo/stores/mutations/tree';
import state from '~/repo/stores/state';
import { file } from '../../helpers';
describe('Multi-file store tree mutations', () => {
let localState;
let localTree;
beforeEach(() => {
localState = state();
localTree = file();
});
describe('TOGGLE_TREE_OPEN', () => {
it('toggles tree open', () => {
mutations.TOGGLE_TREE_OPEN(localState, localTree);
expect(localTree.opened).toBeTruthy();
mutations.TOGGLE_TREE_OPEN(localState, localTree);
expect(localTree.opened).toBeFalsy();
});
});
describe('SET_DIRECTORY_DATA', () => {
const data = [{
name: 'tree',
},
{
name: 'submodule',
},
{
name: 'blob',
}];
it('adds directory data', () => {
mutations.SET_DIRECTORY_DATA(localState, {
data,
tree: localState,
});
expect(localState.tree.length).toBe(3);
expect(localState.tree[0].name).toBe('tree');
expect(localState.tree[1].name).toBe('submodule');
expect(localState.tree[2].name).toBe('blob');
});
});
describe('SET_PARENT_TREE_URL', () => {
it('sets the parent tree url', () => {
mutations.SET_PARENT_TREE_URL(localState, 'test');
expect(localState.parentTreeUrl).toBe('test');
});
});
describe('CREATE_TMP_TREE', () => {
it('adds tree into parent tree', () => {
const tmpEntry = file();
mutations.CREATE_TMP_TREE(localState, {
tmpEntry,
parent: localTree,
});
expect(localTree.tree.length).toBe(1);
expect(localTree.tree[0].name).toBe(tmpEntry.name);
});
});
});
import mutations from '~/repo/stores/mutations';
import state from '~/repo/stores/state';
import { file } from '../helpers';
describe('Multi-file store mutations', () => {
let localState;
let entry;
beforeEach(() => {
localState = state();
entry = file();
});
describe('SET_INITIAL_DATA', () => {
it('sets all initial data', () => {
mutations.SET_INITIAL_DATA(localState, {
test: 'test',
});
expect(localState.test).toBe('test');
});
});
describe('SET_PREVIEW_MODE', () => {
it('sets currentBlobView to repo-preview', () => {
mutations.SET_PREVIEW_MODE(localState);
expect(localState.currentBlobView).toBe('repo-preview');
localState.currentBlobView = 'testing';
mutations.SET_PREVIEW_MODE(localState);
expect(localState.currentBlobView).toBe('repo-preview');
});
});
describe('SET_EDIT_MODE', () => {
it('sets currentBlobView to repo-editor', () => {
mutations.SET_EDIT_MODE(localState);
expect(localState.currentBlobView).toBe('repo-editor');
localState.currentBlobView = 'testing';
mutations.SET_EDIT_MODE(localState);
expect(localState.currentBlobView).toBe('repo-editor');
});
});
describe('TOGGLE_LOADING', () => {
it('toggles loading of entry', () => {
mutations.TOGGLE_LOADING(localState, entry);
expect(entry.loading).toBeTruthy();
mutations.TOGGLE_LOADING(localState, entry);
expect(entry.loading).toBeFalsy();
});
});
describe('TOGGLE_EDIT_MODE', () => {
it('toggles editMode', () => {
mutations.TOGGLE_EDIT_MODE(localState);
expect(localState.editMode).toBeTruthy();
mutations.TOGGLE_EDIT_MODE(localState);
expect(localState.editMode).toBeFalsy();
});
});
describe('TOGGLE_DISCARD_POPUP', () => {
it('sets discardPopupOpen', () => {
mutations.TOGGLE_DISCARD_POPUP(localState, true);
expect(localState.discardPopupOpen).toBeTruthy();
mutations.TOGGLE_DISCARD_POPUP(localState, false);
expect(localState.discardPopupOpen).toBeFalsy();
});
});
describe('SET_COMMIT_REF', () => {
it('sets currentRef', () => {
mutations.SET_COMMIT_REF(localState, '123');
expect(localState.currentRef).toBe('123');
});
});
describe('SET_ROOT', () => {
it('sets isRoot & initialRoot', () => {
mutations.SET_ROOT(localState, true);
expect(localState.isRoot).toBeTruthy();
expect(localState.isInitialRoot).toBeTruthy();
mutations.SET_ROOT(localState, false);
expect(localState.isRoot).toBeFalsy();
expect(localState.isInitialRoot).toBeFalsy();
});
});
describe('SET_PREVIOUS_URL', () => {
it('sets previousUrl', () => {
mutations.SET_PREVIOUS_URL(localState, 'testing');
expect(localState.previousUrl).toBe('testing');
});
});
});
import * as utils from '~/repo/stores/utils';
describe('Multi-file store utils', () => {
describe('setPageTitle', () => {
it('sets the document page title', () => {
utils.setPageTitle('test');
expect(document.title).toBe('test');
});
});
describe('pushState', () => {
it('calls history.pushState', () => {
spyOn(history, 'pushState');
utils.pushState('test');
expect(history.pushState).toHaveBeenCalledWith({ url: 'test' }, '', 'test');
});
});
describe('createTemp', () => {
it('creates temp tree', () => {
const tmp = utils.createTemp({
name: 'test',
path: 'test',
type: 'tree',
level: 0,
changed: false,
content: '',
base64: '',
});
expect(tmp.tempFile).toBeTruthy();
expect(tmp.icon).toBe('fa-folder');
});
it('creates temp file', () => {
const tmp = utils.createTemp({
name: 'test',
path: 'test',
type: 'blob',
level: 0,
changed: false,
content: '',
base64: '',
});
expect(tmp.tempFile).toBeTruthy();
expect(tmp.icon).toBe('fa-file-text-o');
});
});
describe('findIndexOfFile', () => {
let state;
beforeEach(() => {
state = [{
path: '1',
}, {
path: '2',
}];
});
it('finds in the index of an entry by path', () => {
const index = utils.findIndexOfFile(state, {
path: '2',
});
expect(index).toBe(1);
});
});
describe('findEntry', () => {
let state;
beforeEach(() => {
state = {
tree: [{
type: 'tree',
name: 'test',
}, {
type: 'blob',
name: 'file',
}],
};
});
it('returns an entry found by name', () => {
const foundEntry = utils.findEntry(state, 'tree', 'test');
expect(foundEntry.type).toBe('tree');
expect(foundEntry.name).toBe('test');
});
it('returns undefined when no entry found', () => {
const foundEntry = utils.findEntry(state, 'blob', 'test');
expect(foundEntry).toBeUndefined();
});
});
});
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