summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClement Ho <ClemMakesApps@gmail.com>2017-11-13 13:21:16 -0600
committerClement Ho <ClemMakesApps@gmail.com>2017-11-13 13:21:16 -0600
commit421acc9a539725e9dd7f046c19b0b338f1257ce5 (patch)
tree11abe24ca4f272b25c00723ca1acb02dfc7126c2
parent74b87f02db2ebda0b2b16a60dd6759fe6e8de95a (diff)
parent2932f532c35a1fff58486920a03227862594f54d (diff)
downloadgitlab-ce-421acc9a539725e9dd7f046c19b0b338f1257ce5.tar.gz
Merge branch 'master' into backport-add-epic-sidebar
-rw-r--r--.gitlab-ci.yml6
-rw-r--r--CHANGELOG.md16
-rw-r--r--app/assets/javascripts/abuse_reports.js4
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.js3
-rw-r--r--app/assets/javascripts/commits.js4
-rw-r--r--app/assets/javascripts/create_label.js3
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js4
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js3
-rw-r--r--app/assets/javascripts/gl_dropdown.js2
-rw-r--r--app/assets/javascripts/gl_form.js5
-rw-r--r--app/assets/javascripts/issue.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js153
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js234
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/merge_request.js3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue4
-rw-r--r--app/assets/javascripts/repo/stores/actions.js4
-rw-r--r--app/assets/javascripts/repo/stores/actions/branch.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js4
-rw-r--r--app/assets/javascripts/wikis.js3
-rw-r--r--app/finders/autocomplete_users_finder.rb2
-rw-r--r--app/models/concerns/milestoneish.rb8
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/merge_request.rb4
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/services/ci/pipeline_trigger_service.rb8
-rw-r--r--app/services/issuable_base_service.rb6
-rw-r--r--app/services/issues/base_service.rb4
-rw-r--r--app/views/admin/runners/index.html.haml2
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml13
-rw-r--r--app/views/notify/_note_email.html.haml11
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml2
-rw-r--r--app/views/shared/icons/_icon_hourglass.svg1
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml16
-rw-r--r--changelogs/unreleased/32059-fix-oauth-phishing.yml6
-rw-r--r--changelogs/unreleased/38075_allow_refernce_integer_labels.yml5
-rw-r--r--changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml5
-rw-r--r--changelogs/unreleased/39335-add-time-spend-to-milestones.yml5
-rw-r--r--changelogs/unreleased/39436-pages-api-administrative.yml5
-rw-r--r--changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml5
-rw-r--r--changelogs/unreleased/39704_fix_webhooks_log_time.yml5
-rw-r--r--changelogs/unreleased/40068-runner-sorting-regression.yml5
-rw-r--r--changelogs/unreleased/bvl-unlink-fixes.yml5
-rw-r--r--changelogs/unreleased/dm-authorize-admin-oauth-application.yml6
-rw-r--r--changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml6
-rw-r--r--changelogs/unreleased/fix-import-export-arguments.yml5
-rw-r--r--changelogs/unreleased/fix-subgroup-autocomplete.yml5
-rw-r--r--changelogs/unreleased/fix_diff_parsing.yml5
-rw-r--r--changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml5
-rw-r--r--changelogs/unreleased/issue_39176.yml5
-rw-r--r--changelogs/unreleased/issue_39238.yml5
-rw-r--r--changelogs/unreleased/issue_39773_be.yml5
-rw-r--r--changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml5
-rw-r--r--changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml5
-rw-r--r--changelogs/unreleased/sh-fix-environment-slug-generation.yml5
-rw-r--r--changelogs/unreleased/text-utils.yml5
-rw-r--r--doc/api/pages_domains.md25
-rw-r--r--doc/api/services.md35
-rw-r--r--doc/user/project/members/index.md2
-rw-r--r--lib/api/api_guard.rb8
-rw-r--r--lib/api/entities.rb20
-rw-r--r--lib/api/helpers.rb9
-rw-r--r--lib/api/pages_domains.rb22
-rw-r--r--lib/gitlab/git/repository.rb2
-rw-r--r--lib/gitlab/utils/strong_memoize.rb31
-rw-r--r--qa/qa/page/base.rb2
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb18
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb22
-rw-r--r--spec/features/milestone_spec.rb29
-rw-r--r--spec/finders/autocomplete_users_finder_spec.rb15
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json18
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json20
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pages_domains.json21
-rw-r--r--spec/javascripts/gfm_auto_complete_spec.js22
-rw-r--r--spec/javascripts/lib/utils/text_markdown_spec.js62
-rw-r--r--spec/javascripts/lib/utils/text_utility_spec.js120
-rw-r--r--spec/javascripts/repo/helpers.js5
-rw-r--r--spec/javascripts/repo/stores/actions/branch_spec.js38
-rw-r--r--spec/javascripts/repo/stores/actions/file_spec.js417
-rw-r--r--spec/javascripts/repo/stores/actions/tree_spec.js469
-rw-r--r--spec/javascripts/repo/stores/actions_spec.js419
-rw-r--r--spec/javascripts/repo/stores/getters_spec.js119
-rw-r--r--spec/javascripts/repo/stores/mutations/branch_spec.js18
-rw-r--r--spec/javascripts/repo/stores/mutations/file_spec.js131
-rw-r--r--spec/javascripts/repo/stores/mutations/tree_spec.js71
-rw-r--r--spec/javascripts/repo/stores/mutations_spec.js117
-rw-r--r--spec/javascripts/repo/stores/utils_spec.js102
-rw-r--r--spec/lib/gitlab/utils/strong_memoize_spec.rb52
-rw-r--r--spec/mailers/notify_spec.rb20
-rw-r--r--spec/models/concerns/milestoneish_spec.rb17
-rw-r--r--spec/models/issue_spec.rb18
-rw-r--r--spec/models/merge_request_spec.rb12
-rw-r--r--spec/requests/api/pages_domains_spec.rb47
-rw-r--r--vendor/assets/javascripts/latinise.js11
98 files changed, 2787 insertions, 479 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5710effc39d..65f2bc7045f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -416,12 +416,8 @@ ee_compat_check:
- /^[\d-]+-stable(-ee)?/
- branches@gitlab-org/gitlab-ee
- branches@gitlab/gitlab-ee
- allow_failure: yes
+ allow_failure: no
retry: 0
- cache:
- key: "ee_compat_check_repo"
- paths:
- - ee_compat_check/ee-repo/
artifacts:
name: "${CI_JOB_NAME}_${CI_COMIT_REF_NAME}_${CI_COMMIT_SHA}"
when: on_failure
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2482f0124bd..4294ccaf9b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
+## 10.1.3 (2017-11-10)
+
+- [SECURITY] Prevent OAuth phishing attack by presenting detailed wording about app to user during authorization.
+- [FIXED] Fix cancel button not working while uploading on the new issue page. !15137
+- [FIXED] Fix webhooks recent deliveries. !15146 (Alexander Randa (@randaalex))
+- [FIXED] Fix issues with forked projects of which the source was deleted. !15150
+- [FIXED] Fix GPG signature popup info in Safari and Firefox. !15228
+- [FIXED] Make sure group and project creation is blocked for new users that are external by default.
+- [FIXED] Fix arguments Import/Export error importing project merge requests.
+- [FIXED] Fix diff parser so it tolerates to diff special markers in the content.
+- [FIXED] Fix a migration that adds merge_requests_ff_only_enabled column to MR table.
+- [FIXED] Render 404 when polling commit notes without having permissions.
+- [FIXED] Show error message when fast-forward merge is not possible.
+- [FIXED] Avoid regenerating the ref path for the environment.
+- [PERFORMANCE] Remove Filesystem check metrics that use too much CPU to handle requests.
+
## 10.1.2 (2017-11-08)
- [SECURITY] Add X-Content-Type-Options header in API responses to make it more difficult to find other vulnerabilities.
diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js
index 3de192d56eb..d2d3a257c0d 100644
--- a/app/assets/javascripts/abuse_reports.js
+++ b/app/assets/javascripts/abuse_reports.js
@@ -1,3 +1,5 @@
+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));
}
}
diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js
index de9e44cef35..182957113a2 100644
--- a/app/assets/javascripts/boards/components/modal/footer.js
+++ b/app/assets/javascripts/boards/components/modal/footer.js
@@ -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: {
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index ae6b8902032..9b952ea7b60 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -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'));
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 3bed0678350..9a4c9bfcc80 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,5 +1,6 @@
/* 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/>');
}
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 8bf9ae17de0..a8cd8c20f8f 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -1,7 +1,7 @@
/* 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];
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index fc0308b81ba..9d25f806c0d 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -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';
@@ -139,7 +139,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,
};
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 5c624b79d45..a642464c920 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -338,7 +338,8 @@ class GfmAutoComplete {
let resultantValue = value;
if (value && !this.setting.skipSpecialCharacterTest) {
const withoutAt = value.substring(1);
- if (withoutAt && /[^\w\d]/.test(withoutAt)) {
+ const regex = value.charAt() === '~' ? /\W|^\d+$/ : /\W/;
+ if (withoutAt && regex.test(withoutAt)) {
resultantValue = `${value.charAt()}"${withoutAt}"`;
}
}
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index c4202f92443..4e7a6e54f90 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -331,7 +331,7 @@ GitLabDropdown = (function() {
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
- return $(selector);
+ return $(selector, this.instance.dropdown);
};
})(this),
data: (function(_this) {
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 48cd43d3348..d0f9e6af0f8 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -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() {
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index acd5730cf3c..7de07e9403d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,6 +1,6 @@
/* 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) {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 07899777a1e..5c4926d6ac8 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -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;
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 8d0bab55843..52bdc05a58c 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -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;
}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
new file mode 100644
index 00000000000..2dc9cf0cc29
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -0,0 +1,153 @@
+/* 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;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 0decfff6921..5f027769d4f 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,18 +1,13 @@
-/* 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,182 +15,53 @@ gl.text.addDelimiter = function(text) {
* @param {Number} count
* @return {Number|String}
*/
-export function highCountTrim(count) {
- return count > 99 ? '99+' : count;
-}
+export const highCountTrim = count => (count > 99 ? '99+' : count);
+/**
+ * Capitalizes first character
+ *
+ * @param {String} text
+ * @return {String}
+ */
export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
-gl.text.randomString = function() {
- return Math.random().toString(36).substring(7);
-};
-gl.text.replaceRange = function(s, start, end, substitute) {
- return s.substring(0, start) + substitute + s.substring(end);
-};
-gl.text.getTextWidth = function(text, font) {
- /**
- * Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
- *
- * @param {String} text The text to be rendered.
- * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
- *
- * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
- */
- // re-use canvas object for better performance
- var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas'));
- var context = canvas.getContext('2d');
- context.font = font;
- return context.measureText(text).width;
-};
-gl.text.selectedText = function(text, textarea) {
- return text.substring(textarea.selectionStart, textarea.selectionEnd);
-};
-gl.text.lineBefore = function(text, textarea) {
- var split;
- split = text.substring(0, textarea.selectionStart).trim().split('\n');
- return split[split.length - 1];
-};
-gl.text.lineAfter = function(text, textarea) {
- return text.substring(textarea.selectionEnd).trim().split('\n')[0];
-};
-gl.text.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;
- }
-};
-gl.text.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 : ' ');
- }
+/**
+ * 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();
-};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 31c5cfc5e55..127fddcf8d3 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -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
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index af0658eb668..d30ff12bb59 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -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() {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 547140b1a43..19d8e1f49cf 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,7 +1,7 @@
<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}`;
},
},
diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js
index be290c268b1..120ce96f44d 100644
--- a/app/assets/javascripts/repo/stores/actions.js
+++ b/app/assets/javascripts/repo/stores/actions.js
@@ -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);
diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js
index b81a70dfd1e..61d9a5af3e3 100644
--- a/app/assets/javascripts/repo/stores/actions/branch.js
+++ b/app/assets/javascripts/repo/stores/actions/branch.js
@@ -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);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
index 219ff94924e..13e4cb5717e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
@@ -1,5 +1,5 @@
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
diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js
index a0025ddb598..7a865587444 100644
--- a/app/assets/javascripts/wikis.js
+++ b/app/assets/javascripts/wikis.js
@@ -1,4 +1,5 @@
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');
diff --git a/app/finders/autocomplete_users_finder.rb b/app/finders/autocomplete_users_finder.rb
index b8f52e31926..c3f5358b577 100644
--- a/app/finders/autocomplete_users_finder.rb
+++ b/app/finders/autocomplete_users_finder.rb
@@ -45,7 +45,7 @@ class AutocompleteUsersFinder
def find_users
return users_from_project if project
- return group.users if group
+ return group.users_with_parents if group
return User.all if current_user
User.none
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 710fc1ed647..7026f565706 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -86,6 +86,14 @@ module Milestoneish
false
end
+ def total_issue_time_spent
+ @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent)
+ end
+
+ def human_total_issue_time_spent
+ Gitlab::TimeTrackingFormatter.output(total_issue_time_spent)
+ end
+
private
def count_issues_by_state(user)
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 3b3c7fb7f8b..b5abc8f57b0 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -262,10 +262,6 @@ class Issue < ActiveRecord::Base
true
end
- def update_project_counter_caches?
- state_changed? || confidential_changed?
- end
-
def update_project_counter_caches
Projects::OpenIssuesCountService.new(project).refresh_cache
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index dd4e67bc9da..f1a5cc73e83 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -958,10 +958,6 @@ class MergeRequest < ActiveRecord::Base
true
end
- def update_project_counter_caches?
- state_changed?
- end
-
def update_project_counter_caches
Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 2e824cda525..43c77f3f2a2 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -69,6 +69,10 @@ class PagesDomain < ActiveRecord::Base
current < x509.not_before || x509.not_after < current
end
+ def expiration
+ x509&.not_after
+ end
+
def subject
return unless x509
x509.subject.to_s
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index 120af8c1e61..a9813d774bb 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -1,5 +1,7 @@
module Ci
class PipelineTriggerService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
def execute
if trigger_from_token
create_pipeline_from_trigger(trigger_from_token)
@@ -26,9 +28,9 @@ module Ci
end
def trigger_from_token
- return @trigger if defined?(@trigger)
-
- @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
+ strong_memoize(:trigger) do
+ Ci::Trigger.find_by_token(params[:token].to_s)
+ end
end
def create_pipeline_variables!(pipeline)
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 68b49d880f7..90865867ff0 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -187,7 +187,7 @@ class IssuableBaseService < BaseService
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
- update_project_counters = issuable.project && issuable.update_project_counter_caches?
+ update_project_counters = issuable.project && update_project_counter_caches?(issuable)
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
@@ -288,4 +288,8 @@ class IssuableBaseService < BaseService
# override if needed
def execute_hooks(issuable, action = 'open', params = {})
end
+
+ def update_project_counter_caches?(issuable)
+ issuable.state_changed?
+ end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 735257c4779..b680eaf5a49 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -45,5 +45,9 @@ module Issues
params.delete(:assignee_ids)
end
end
+
+ def update_project_counter_caches?(issue)
+ super || issue.confidential_changed?
+ end
end
end
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 4965dffab9d..4f60be698e9 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -64,7 +64,7 @@
%th Projects
%th Jobs
%th Tags
- %th Last contact
+ %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc'))
%th
- @runners.each do |runner|
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 85e4170aee9..6d9c6b5572a 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -1,5 +1,3 @@
-- auth_app_owner = @pre_auth.client.application.owner
-
%main{ :role => "main" }
.modal-no-backdrop.modal-doorkeepr-auth
.modal-content
@@ -20,9 +18,14 @@
%p
An application called
= link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer'
- 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)
+ is requesting access to your GitLab account.
+
+ - auth_app_owner = @pre_auth.client.application.owner
+ - if auth_app_owner
+ 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
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index a80518f7986..3e36da31ea3 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -1,10 +1,15 @@
- discussion = @note.discussion if @note.part_of_discussion?
+- diff_discussion = discussion&.diff_discussion?
+- on_image = discussion.on_image? if diff_discussion
+
- if discussion
+ - phrase_end_char = on_image ? "." : ":"
+
%p.details
- = succeed ':' do
+ = succeed phrase_end_char do
= link_to @note.author_name, user_url(@note.author)
- - if discussion.diff_discussion?
+ - if diff_discussion
- if discussion.new_discussion?
started a new discussion
- else
@@ -21,7 +26,7 @@
%p.details
#{link_to @note.author_name, user_url(@note.author)} commented:
-- if discussion&.diff_discussion?
+- if diff_discussion && !on_image
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
index ea91e8af70e..f53b81cada6 100644
--- a/app/views/projects/protected_tags/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -2,7 +2,7 @@
.create_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide',
- dropdown_class: 'dropdown-menu-selectable',
+ dropdown_class: 'dropdown-menu-selectable capitalize-header',
data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
= render 'projects/protected_tags/shared/create_protected_tag'
diff --git a/app/views/shared/icons/_icon_hourglass.svg b/app/views/shared/icons/_icon_hourglass.svg
new file mode 100644
index 00000000000..fe7e497ce13
--- /dev/null
+++ b/app/views/shared/icons/_icon_hourglass.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.331 4.889A2.988 2.988 0 0 0 11 3V2H5v1c0 .362.064.709.182 1.03l5.15.859zM3 14v-1c0-1.78.93-3.342 2.33-4.228.447-.327.67-.582.67-.764 0-.19-.242-.46-.725-.815A4.996 4.996 0 0 1 3 3V2H2a1 1 0 1 1 0-2h12a1 1 0 0 1 0 2h-1v1a4.997 4.997 0 0 1-2.39 4.266c-.407.3-.61.545-.61.734 0 .19.203.434.61.734A4.997 4.997 0 0 1 13 13v1h1a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2h1zm8 0v-1a3 3 0 0 0-6 0v1h6z"/></svg>
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index f03e0ab154c..4f51455c26e 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -85,6 +85,22 @@
Closed:
= milestone.issues_visible_to_user(current_user).closed.count
+ .block.time_spent
+ .sidebar-collapsed-icon
+ = custom_icon('icon_hourglass')
+ %span.collapsed-milestone-total-time-spent
+ - if milestone.human_total_issue_time_spent
+ = milestone.human_total_issue_time_spent
+ - else
+ = _("None")
+ .title.hide-collapsed
+ = _("Total issue time spent")
+ .value.hide-collapsed
+ - if milestone.human_total_issue_time_spent
+ %span.bold= milestone.human_total_issue_time_spent
+ - else
+ %span.no-value= _("No time spent")
+
.block.merge-requests
.sidebar-collapsed-icon
%strong
diff --git a/changelogs/unreleased/32059-fix-oauth-phishing.yml b/changelogs/unreleased/32059-fix-oauth-phishing.yml
deleted file mode 100644
index 1aaa7285309..00000000000
--- a/changelogs/unreleased/32059-fix-oauth-phishing.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Prevent OAuth phishing attack by presenting detailed wording about app to user
- during authorization
-merge_request:
-author:
-type: security
diff --git a/changelogs/unreleased/38075_allow_refernce_integer_labels.yml b/changelogs/unreleased/38075_allow_refernce_integer_labels.yml
new file mode 100644
index 00000000000..b5342d4adf8
--- /dev/null
+++ b/changelogs/unreleased/38075_allow_refernce_integer_labels.yml
@@ -0,0 +1,5 @@
+---
+title: Fix errors when selecting numeric-only labels in the labels autocomplete selector
+merge_request: 14607
+author: haseebeqx
+type: fixed
diff --git a/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml b/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml
deleted file mode 100644
index c7e840f0723..00000000000
--- a/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix GPG signature popup info in Safari and Firefox
-merge_request: 15228
-author:
-type: fixed
diff --git a/changelogs/unreleased/39335-add-time-spend-to-milestones.yml b/changelogs/unreleased/39335-add-time-spend-to-milestones.yml
new file mode 100644
index 00000000000..41a43418cbf
--- /dev/null
+++ b/changelogs/unreleased/39335-add-time-spend-to-milestones.yml
@@ -0,0 +1,5 @@
+---
+title: Add total time spent to milestones
+merge_request: 15116
+author: George Andrinopoulos
+type: added
diff --git a/changelogs/unreleased/39436-pages-api-administrative.yml b/changelogs/unreleased/39436-pages-api-administrative.yml
new file mode 100644
index 00000000000..f38bbbd479c
--- /dev/null
+++ b/changelogs/unreleased/39436-pages-api-administrative.yml
@@ -0,0 +1,5 @@
+---
+title: Add administrative endpoint to list all pages domains
+merge_request: 15160
+author: Travis Miller
+type: added
diff --git a/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml b/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml
new file mode 100644
index 00000000000..056afe43010
--- /dev/null
+++ b/changelogs/unreleased/39602-move-update-project-counter-caches-out-of-issues-merge-requests.yml
@@ -0,0 +1,5 @@
+---
+title: Move update_project_counter_caches? out of issue and merge request
+merge_request: 15300
+author: George Andrinopoulos
+type: other
diff --git a/changelogs/unreleased/39704_fix_webhooks_log_time.yml b/changelogs/unreleased/39704_fix_webhooks_log_time.yml
deleted file mode 100644
index 1234663e66b..00000000000
--- a/changelogs/unreleased/39704_fix_webhooks_log_time.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix webhooks recent deliveries
-merge_request: 15146
-author: Alexander Randa (@randaalex)
-type: fixed
diff --git a/changelogs/unreleased/40068-runner-sorting-regression.yml b/changelogs/unreleased/40068-runner-sorting-regression.yml
new file mode 100644
index 00000000000..6a2bd59d6d6
--- /dev/null
+++ b/changelogs/unreleased/40068-runner-sorting-regression.yml
@@ -0,0 +1,5 @@
+---
+title: Revert a regression on runners sorting (!15134)
+merge_request: 15341
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/bvl-unlink-fixes.yml b/changelogs/unreleased/bvl-unlink-fixes.yml
deleted file mode 100644
index 685d78f479d..00000000000
--- a/changelogs/unreleased/bvl-unlink-fixes.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix issues with forked projects of which the source was deleted
-merge_request: 15150
-author:
-type: fixed
diff --git a/changelogs/unreleased/dm-authorize-admin-oauth-application.yml b/changelogs/unreleased/dm-authorize-admin-oauth-application.yml
new file mode 100644
index 00000000000..2787485bc28
--- /dev/null
+++ b/changelogs/unreleased/dm-authorize-admin-oauth-application.yml
@@ -0,0 +1,6 @@
+---
+title: Prevent error when authorizing an admin-created OAauth application without
+ a set owner
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml b/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml
deleted file mode 100644
index 42bcf9b1edd..00000000000
--- a/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Make sure group and project creation is blocked for new users that are external
- by default
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-import-export-arguments.yml b/changelogs/unreleased/fix-import-export-arguments.yml
deleted file mode 100644
index eee87e313ea..00000000000
--- a/changelogs/unreleased/fix-import-export-arguments.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix arguments Import/Export error importing project merge requests
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix-subgroup-autocomplete.yml b/changelogs/unreleased/fix-subgroup-autocomplete.yml
new file mode 100644
index 00000000000..4baa2b02f77
--- /dev/null
+++ b/changelogs/unreleased/fix-subgroup-autocomplete.yml
@@ -0,0 +1,5 @@
+---
+title: Fix user autocomplete in subgroups
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/fix_diff_parsing.yml b/changelogs/unreleased/fix_diff_parsing.yml
deleted file mode 100644
index 7a26b4f9ff5..00000000000
--- a/changelogs/unreleased/fix_diff_parsing.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix diff parser so it tolerates to diff special markers in the content
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml b/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml
deleted file mode 100644
index a1685497331..00000000000
--- a/changelogs/unreleased/fix_migration_that_adds_ff_merge_field.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix a migration that adds merge_requests_ff_only_enabled column to MR table
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/issue_39176.yml b/changelogs/unreleased/issue_39176.yml
deleted file mode 100644
index 6255b51c094..00000000000
--- a/changelogs/unreleased/issue_39176.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Render 404 when polling commit notes without having permissions
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/issue_39238.yml b/changelogs/unreleased/issue_39238.yml
new file mode 100644
index 00000000000..75a4969ca9e
--- /dev/null
+++ b/changelogs/unreleased/issue_39238.yml
@@ -0,0 +1,5 @@
+---
+title: Fix image diff notification email from showing wrong content
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/issue_39773_be.yml b/changelogs/unreleased/issue_39773_be.yml
deleted file mode 100644
index db30267d71f..00000000000
--- a/changelogs/unreleased/issue_39773_be.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Show error message when fast-forward merge is not possible
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml b/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml
deleted file mode 100644
index 0205d9626b1..00000000000
--- a/changelogs/unreleased/jivl-fix-cancel-button-file-upload-new-issue.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Fix cancel button not working while uploading on the new issue page
-merge_request: 15137
-author:
-type: fixed
diff --git a/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml b/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml
deleted file mode 100644
index 556d7d069d3..00000000000
--- a/changelogs/unreleased/pawel-disable_nfs_metrics_checks_39730.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Remove Filesystem check metrics that use too much CPU to handle requests
-merge_request:
-author:
-type: performance
diff --git a/changelogs/unreleased/sh-fix-environment-slug-generation.yml b/changelogs/unreleased/sh-fix-environment-slug-generation.yml
deleted file mode 100644
index 8a9c670c52c..00000000000
--- a/changelogs/unreleased/sh-fix-environment-slug-generation.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Avoid regenerating the ref path for the environment
-merge_request:
-author:
-type: fixed
diff --git a/changelogs/unreleased/text-utils.yml b/changelogs/unreleased/text-utils.yml
new file mode 100644
index 00000000000..b95bb82fe01
--- /dev/null
+++ b/changelogs/unreleased/text-utils.yml
@@ -0,0 +1,5 @@
+---
+title: Export text utils functions as es6 module and add tests
+merge_request:
+author:
+type: other
diff --git a/doc/api/pages_domains.md b/doc/api/pages_domains.md
index 51962595e33..50685f335f7 100644
--- a/doc/api/pages_domains.md
+++ b/doc/api/pages_domains.md
@@ -4,6 +4,31 @@ Endpoints for connecting custom domain(s) and TLS certificates in [GitLab Pages]
The GitLab Pages feature must be enabled to use these endpoints. Find out more about [administering](../administration/pages/index.md) and [using](../user/project/pages/index.md) the feature.
+## List all pages domains
+
+Get a list of all pages domains. The user must have admin permissions.
+
+```http
+GET /pages/domains
+```
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/pages/domains
+```
+
+```json
+[
+ {
+ "domain": "ssl.domain.example",
+ "url": "https://ssl.domain.example",
+ "certificate": {
+ "expired": false,
+ "expiration": "2020-04-12T14:32:00.000Z"
+ }
+ }
+]
+```
+
## List pages domains
Get a list of project pages domains. The user must have permissions to view pages domains.
diff --git a/doc/api/services.md b/doc/api/services.md
index 08a2bee1518..08df26db3ec 100644
--- a/doc/api/services.md
+++ b/doc/api/services.md
@@ -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.
diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md
index b8dd96087f1..43713855e26 100644
--- a/doc/user/project/members/index.md
+++ b/doc/user/project/members/index.md
@@ -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)
diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb
index b9c7d443f6c..c1c0d344917 100644
--- a/lib/api/api_guard.rb
+++ b/lib/api/api_guard.rb
@@ -42,6 +42,8 @@ module API
# Helper Methods for Grape Endpoint
module HelperMethods
+ include Gitlab::Utils::StrongMemoize
+
def find_current_user!
user = find_user_from_access_token || find_user_from_warden
return unless user
@@ -52,9 +54,9 @@ module API
end
def access_token
- return @access_token if defined?(@access_token)
-
- @access_token = find_oauth_access_token || find_personal_access_token
+ strong_memoize(:access_token) do
+ find_oauth_access_token || find_personal_access_token
+ end
end
def validate_access_token!(scopes: [])
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index a382db92e8d..16ae99b5c6c 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1042,6 +1042,11 @@ module API
expose :value
end
+ class PagesDomainCertificateExpiration < Grape::Entity
+ expose :expired?, as: :expired
+ expose :expiration
+ end
+
class PagesDomainCertificate < Grape::Entity
expose :subject
expose :expired?, as: :expired
@@ -1049,12 +1054,23 @@ module API
expose :certificate_text
end
+ class PagesDomainBasic < Grape::Entity
+ expose :domain
+ expose :url
+ expose :certificate,
+ as: :certificate_expiration,
+ if: ->(pages_domain, _) { pages_domain.certificate? },
+ using: PagesDomainCertificateExpiration do |pages_domain|
+ pages_domain
+ end
+ end
+
class PagesDomain < Grape::Entity
expose :domain
expose :url
expose :certificate,
- if: ->(pages_domain, _) { pages_domain.certificate? },
- using: PagesDomainCertificate do |pages_domain|
+ if: ->(pages_domain, _) { pages_domain.certificate? },
+ using: PagesDomainCertificate do |pages_domain|
pages_domain
end
end
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 5f9b94cc89c..3c8960cb1ab 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -155,6 +155,11 @@ module API
end
end
+ def authenticated_with_full_private_access!
+ authenticate!
+ forbidden! unless current_user.full_private_access?
+ end
+
def authenticated_as_admin!
authenticate!
forbidden! unless current_user.admin?
@@ -190,6 +195,10 @@ module API
not_found! unless user_project.pages_available?
end
+ def require_pages_config_enabled!
+ not_found! unless Gitlab.config.pages.enabled
+ end
+
def can?(object, action, subject = :global)
Ability.allowed?(object, action, subject)
end
diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb
index 259f3f34068..d7b613a717e 100644
--- a/lib/api/pages_domains.rb
+++ b/lib/api/pages_domains.rb
@@ -4,7 +4,6 @@ module API
before do
authenticate!
- require_pages_enabled!
end
after_validation do
@@ -29,10 +28,31 @@ module API
end
end
+ resource :pages do
+ before do
+ require_pages_config_enabled!
+ authenticated_with_full_private_access!
+ end
+
+ desc "Get all pages domains" do
+ success Entities::PagesDomainBasic
+ end
+ params do
+ use :pagination
+ end
+ get "domains" do
+ present paginate(PagesDomain.all), with: Entities::PagesDomainBasic
+ end
+ end
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: { id: %r{[^/]+} } do
+ before do
+ require_pages_enabled!
+ end
+
desc 'Get all pages domains' do
success Entities::PagesDomain
end
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 14a8d551524..cfb88a0c12b 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -104,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
diff --git a/lib/gitlab/utils/strong_memoize.rb b/lib/gitlab/utils/strong_memoize.rb
new file mode 100644
index 00000000000..a2ac9285b56
--- /dev/null
+++ b/lib/gitlab/utils/strong_memoize.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module Utils
+ module StrongMemoize
+ # Instead of writing patterns like this:
+ #
+ # def trigger_from_token
+ # return @trigger if defined?(@trigger)
+ #
+ # @trigger = Ci::Trigger.find_by_token(params[:token].to_s)
+ # end
+ #
+ # We could write it like:
+ #
+ # def trigger_from_token
+ # strong_memoize(:trigger) do
+ # Ci::Trigger.find_by_token(params[:token].to_s)
+ # end
+ # end
+ #
+ def strong_memoize(name)
+ ivar_name = "@#{name}"
+
+ if instance_variable_defined?(ivar_name)
+ instance_variable_get(ivar_name)
+ else
+ instance_variable_set(ivar_name, yield)
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb
index d55326c5262..bdddfb877c5 100644
--- a/qa/qa/page/base.rb
+++ b/qa/qa/page/base.rb
@@ -5,7 +5,7 @@ module QA
include Scenario::Actable
def refresh
- visit current_path
+ visit current_url
end
end
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index b8a66245153..95d637265e0 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -218,18 +218,18 @@ feature 'GFM autocomplete', :js do
user_item = find('.atwho-view li', text: user.username)
expect(user_item).to have_content(user.username)
end
+ end
- def expect_to_wrap(should_wrap, item, note, value)
- expect(item).to have_content(value)
- expect(item).not_to have_content("\"#{value}\"")
+ def expect_to_wrap(should_wrap, item, note, value)
+ expect(item).to have_content(value)
+ expect(item).not_to have_content("\"#{value}\"")
- item.click
+ item.click
- if should_wrap
- expect(note.value).to include("\"#{value}\"")
- else
- expect(note.value).not_to include("\"#{value}\"")
- end
+ if should_wrap
+ expect(note.value).to include("\"#{value}\"")
+ else
+ expect(note.value).not_to include("\"#{value}\"")
end
end
end
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index 5402d61da54..db5ce2d11a8 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -67,6 +67,28 @@ feature 'Create New Merge Request', :js do
expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch'
end
+ it 'allows filtering multiple dropdowns' do
+ visit project_new_merge_request_path(project)
+
+ first('.js-source-branch').click
+
+ input = find('.dropdown-source-branch .dropdown-input-field')
+ input.click
+ input.send_keys('orphaned-branch')
+
+ find('.dropdown-source-branch .dropdown-content li', match: :first)
+ source_items = all('.dropdown-source-branch .dropdown-content li')
+
+ expect(source_items.count).to eq(1)
+
+ first('.js-target-branch').click
+
+ find('.dropdown-target-branch .dropdown-content li', match: :first)
+ target_items = all('.dropdown-target-branch .dropdown-content li')
+
+ expect(target_items.count).to be > 1
+ end
+
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)
diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb
index 6c9dc67ad74..27efc32c95b 100644
--- a/spec/features/milestone_spec.rb
+++ b/spec/features/milestone_spec.rb
@@ -65,4 +65,33 @@ feature 'Milestone' do
expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
end
+
+ feature 'Open a milestone' do
+ scenario 'shows total issue time spent correctly when no time has been logged' do
+ milestone = create(:milestone, project: project, title: 8.7)
+
+ visit project_milestone_path(project, milestone)
+
+ page.within('.block.time_spent') do
+ expect(page).to have_content 'No time spent'
+ expect(page).to have_content 'None'
+ end
+ end
+
+ scenario 'shows total issue time spent' do
+ milestone = create(:milestone, project: project, title: 8.7)
+ issue1 = create(:issue, project: project, milestone: milestone)
+ issue2 = create(:issue, project: project, milestone: milestone)
+ issue1.spend_time(duration: 3600, user: user)
+ issue1.save!
+ issue2.spend_time(duration: 7200, user: user)
+ issue2.save!
+
+ visit project_milestone_path(project, milestone)
+
+ page.within('.block.time_spent') do
+ expect(page).to have_content '3h'
+ end
+ end
+ end
end
diff --git a/spec/finders/autocomplete_users_finder_spec.rb b/spec/finders/autocomplete_users_finder_spec.rb
index 684af74d750..dcf9111776e 100644
--- a/spec/finders/autocomplete_users_finder_spec.rb
+++ b/spec/finders/autocomplete_users_finder_spec.rb
@@ -42,6 +42,21 @@ describe AutocompleteUsersFinder do
it { is_expected.to match_array([user1]) }
end
+ context 'when passed a subgroup', :nested_groups do
+ let(:grandparent) { create(:group, :public) }
+ let(:parent) { create(:group, :public, parent: grandparent) }
+ let(:child) { create(:group, :public, parent: parent) }
+ let(:group) { parent }
+
+ let!(:grandparent_user) { create(:group_member, :developer, group: grandparent).user }
+ let!(:parent_user) { create(:group_member, :developer, group: parent).user }
+ let!(:child_user) { create(:group_member, :developer, group: child).user }
+
+ it 'includes users from parent groups as well' do
+ expect(subject).to match_array([grandparent_user, parent_user])
+ end
+ end
+
it { is_expected.to match_array([user1, external_user, omniauth_user, current_user]) }
context 'when filtered by search' do
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json
new file mode 100644
index 00000000000..4ba6422406c
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json
@@ -0,0 +1,18 @@
+{
+ "type": "object",
+ "properties": {
+ "domain": { "type": "string" },
+ "url": { "type": "uri" },
+ "certificate_expiration": {
+ "type": "object",
+ "properties": {
+ "expired": { "type": "boolean" },
+ "expiration": { "type": "string" }
+ },
+ "required": ["expired", "expiration"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["domain", "url"],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json
new file mode 100644
index 00000000000..08db8d47050
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json
@@ -0,0 +1,20 @@
+{
+ "type": "object",
+ "properties": {
+ "domain": { "type": "string" },
+ "url": { "type": "uri" },
+ "certificate": {
+ "type": "object",
+ "properties": {
+ "subject": { "type": "string" },
+ "expired": { "type": "boolean" },
+ "certificate": { "type": "string" },
+ "certificate_text": { "type": "string" }
+ },
+ "required": ["subject", "expired"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["domain", "url"],
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json
new file mode 100644
index 00000000000..c7d86de7d8e
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain_basics.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "pages_domain/basic.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domains.json b/spec/fixtures/api/schemas/public_api/v4/pages_domains.json
index 0de1d0f1228..7c27218dc5a 100644
--- a/spec/fixtures/api/schemas/public_api/v4/pages_domains.json
+++ b/spec/fixtures/api/schemas/public_api/v4/pages_domains.json
@@ -1,23 +1,4 @@
{
"type": "array",
- "items": {
- "type": "object",
- "properties": {
- "domain": { "type": "string" },
- "url": { "type": "uri" },
- "certificate": {
- "type": "object",
- "properties": {
- "subject": { "type": "string" },
- "expired": { "type": "boolean" },
- "certificate": { "type": "string" },
- "certificate_text": { "type": "string" }
- },
- "required": ["subject", "expired"],
- "additionalProperties": false
- }
- },
- "required": ["domain", "url"],
- "additionalProperties": false
- }
+ "items": { "$ref": "pages_domain/detail.json" }
}
diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js
index ad0c7264616..6f357306ec7 100644
--- a/spec/javascripts/gfm_auto_complete_spec.js
+++ b/spec/javascripts/gfm_auto_complete_spec.js
@@ -67,6 +67,28 @@ describe('GfmAutoComplete', function () {
});
});
+ describe('DefaultOptions.beforeInsert', () => {
+ const beforeInsert = (context, value) => (
+ gfmAutoCompleteCallbacks.beforeInsert.call(context, value)
+ );
+
+ const atwhoInstance = { setting: { skipSpecialCharacterTest: false } };
+
+ it('should not quote if value only contains alphanumeric charecters', () => {
+ expect(beforeInsert(atwhoInstance, '@user1')).toBe('@user1');
+ expect(beforeInsert(atwhoInstance, '~label1')).toBe('~label1');
+ });
+
+ it('should quote if value contains any non-alphanumeric characters', () => {
+ expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"');
+ expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"');
+ });
+
+ it('should quote integer labels', () => {
+ expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"');
+ });
+ });
+
describe('DefaultOptions.matcher', function () {
const defaultMatcher = (context, flag, subtext) => (
gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext)
diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js
new file mode 100644
index 00000000000..a95a7e2a5be
--- /dev/null
+++ b/spec/javascripts/lib/utils/text_markdown_spec.js
@@ -0,0 +1,62 @@
+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}* `);
+ });
+ });
+});
diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js
index f2e2ce79d27..1f46c225071 100644
--- a/spec/javascripts/lib/utils/text_utility_spec.js
+++ b/spec/javascripts/lib/utils/text_utility_spec.js
@@ -1,115 +1,65 @@
-import * as textUtility from '~/lib/utils/text_utility';
+import * as textUtils from '~/lib/utils/text_utility';
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', () => {
- expect(textUtility.highCountTrim(105)).toBe('99+');
- expect(textUtility.highCountTrim(100)).toBe('99+');
+ expect(textUtils.highCountTrim(105)).toBe('99+');
+ expect(textUtils.highCountTrim(100)).toBe('99+');
});
it('returns exact number for count < 100', () => {
- expect(textUtility.highCountTrim(45)).toBe(45);
+ expect(textUtils.highCountTrim(45)).toBe(45);
});
});
describe('capitalizeFirstCharacter', () => {
it('returns string with first letter capitalized', () => {
- expect(textUtility.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
+ expect(textUtils.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();
+ describe('humanize', () => {
+ it('should remove underscores and uppercase the first letter', () => {
+ expect(textUtils.humanize('foo_bar')).toEqual('Foo bar');
});
+ });
- afterAll(() => {
- textArea.parentNode.removeChild(textArea);
+ describe('pluralize', () => {
+ it('should pluralize given string', () => {
+ expect(textUtils.pluralize('test', 2)).toBe('tests');
});
- 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}* `);
- });
-
- 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');
});
});
});
diff --git a/spec/javascripts/repo/helpers.js b/spec/javascripts/repo/helpers.js
index 376c291c64b..820a44992b4 100644
--- a/spec/javascripts/repo/helpers.js
+++ b/spec/javascripts/repo/helpers.js
@@ -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(),
- },
});
diff --git a/spec/javascripts/repo/stores/actions/branch_spec.js b/spec/javascripts/repo/stores/actions/branch_spec.js
new file mode 100644
index 00000000000..af9d6835a67
--- /dev/null
+++ b/spec/javascripts/repo/stores/actions/branch_spec.js
@@ -0,0 +1,38 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/actions/file_spec.js b/spec/javascripts/repo/stores/actions/file_spec.js
new file mode 100644
index 00000000000..099c0556e71
--- /dev/null
+++ b/spec/javascripts/repo/stores/actions/file_spec.js
@@ -0,0 +1,417 @@
+import Vue from 'vue';
+import store from '~/repo/stores';
+import service from '~/repo/services';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file store file actions', () => {
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('closeFile', () => {
+ let localFile;
+ let getLastCommitDataSpy;
+ let oldGetLastCommitData;
+
+ beforeEach(() => {
+ getLastCommitDataSpy = jasmine.createSpy('getLastCommitData');
+ oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line
+ store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line
+
+ localFile = file();
+ localFile.active = true;
+ localFile.opened = true;
+ localFile.parentTreeUrl = 'parentTreeUrl';
+
+ store.state.openFiles.push(localFile);
+
+ spyOn(history, 'pushState');
+ });
+
+ afterEach(() => {
+ store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line
+ });
+
+ it('closes open files', (done) => {
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(localFile.opened).toBeFalsy();
+ expect(localFile.active).toBeFalsy();
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('does not close file if has changed', (done) => {
+ localFile.changed = true;
+
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(localFile.opened).toBeTruthy();
+ expect(localFile.active).toBeTruthy();
+ expect(store.state.openFiles.length).toBe(1);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('does not close file if temp file', (done) => {
+ localFile.tempFile = true;
+
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(localFile.opened).toBeTruthy();
+ expect(localFile.active).toBeTruthy();
+ expect(store.state.openFiles.length).toBe(1);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('force closes a changed file', (done) => {
+ localFile.changed = true;
+
+ store.dispatch('closeFile', { file: localFile, force: true })
+ .then(() => {
+ expect(localFile.opened).toBeFalsy();
+ expect(localFile.active).toBeFalsy();
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls pushState when no open files are left', (done) => {
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'parentTreeUrl');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets next file as active', (done) => {
+ const f = file();
+ store.state.openFiles.push(f);
+
+ expect(f.active).toBeFalsy();
+
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(f.active).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls getLastCommitData', (done) => {
+ store.dispatch('closeFile', { file: localFile })
+ .then(() => {
+ expect(getLastCommitDataSpy).toHaveBeenCalled();
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('setFileActive', () => {
+ let scrollToTabSpy;
+ let oldScrollToTab;
+
+ beforeEach(() => {
+ scrollToTabSpy = jasmine.createSpy('scrollToTab');
+ oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
+ store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
+ });
+
+ afterEach(() => {
+ store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
+ });
+
+ it('calls scrollToTab', (done) => {
+ store.dispatch('setFileActive', file())
+ .then(() => {
+ expect(scrollToTabSpy).toHaveBeenCalled();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets the file active', (done) => {
+ const localFile = file();
+
+ store.dispatch('setFileActive', localFile)
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('returns early if file is already active', (done) => {
+ const localFile = file();
+ localFile.active = true;
+
+ store.dispatch('setFileActive', localFile)
+ .then(() => {
+ expect(scrollToTabSpy).not.toHaveBeenCalled();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets current active file to not active', (done) => {
+ const localFile = file();
+ localFile.active = true;
+ store.state.openFiles.push(localFile);
+
+ store.dispatch('setFileActive', file())
+ .then(() => {
+ expect(localFile.active).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('resets location.hash for line highlighting', (done) => {
+ location.hash = 'test';
+
+ store.dispatch('setFileActive', file())
+ .then(() => {
+ expect(location.hash).not.toBe('test');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('getFileData', () => {
+ let localFile = file();
+
+ beforeEach(() => {
+ spyOn(service, 'getFileData').and.returnValue(Promise.resolve({
+ headers: {
+ 'page-title': 'testing getFileData',
+ },
+ json: () => Promise.resolve({
+ blame_path: 'blame_path',
+ commits_path: 'commits_path',
+ permalink: 'permalink',
+ raw_path: 'raw_path',
+ binary: false,
+ html: '123',
+ render_error: '',
+ }),
+ }));
+
+ localFile = file();
+ localFile.url = 'getFileDataURL';
+ });
+
+ it('calls the service', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(() => {
+ expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets the file data', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(localFile.blamePath).toBe('blame_path');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets document title', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(() => {
+ expect(document.title).toBe('testing getFileData');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets the file as active', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('adds the file to open files', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(localFile.name);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('toggles the file loading', (done) => {
+ store.dispatch('getFileData', localFile)
+ .then(() => {
+ expect(localFile.loading).toBeTruthy();
+
+ return Vue.nextTick();
+ })
+ .then(() => {
+ expect(localFile.loading).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('getRawFileData', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw'));
+
+ tmpFile = file();
+ });
+
+ it('calls getRawFileData service method', (done) => {
+ store.dispatch('getRawFileData', tmpFile)
+ .then(() => {
+ expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('updates file raw data', (done) => {
+ store.dispatch('getRawFileData', tmpFile)
+ .then(() => {
+ expect(tmpFile.raw).toBe('raw');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('changeFileContent', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ tmpFile = file();
+ });
+
+ it('updates file content', (done) => {
+ store.dispatch('changeFileContent', {
+ file: tmpFile,
+ content: 'content',
+ })
+ .then(() => {
+ expect(tmpFile.content).toBe('content');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('createTempFile', () => {
+ beforeEach(() => {
+ document.body.innerHTML += '<div class="flash-container"></div>';
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ it('creates temp file', (done) => {
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then((f) => {
+ expect(f.tempFile).toBeTruthy();
+ expect(store.state.tree.length).toBe(1);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('adds tmp file to open files', (done) => {
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then((f) => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(f.name);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets tmp file as active', (done) => {
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then((f) => {
+ expect(f.active).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('enters edit mode if file is not base64', (done) => {
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then(() => {
+ expect(store.state.editMode).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('does not enter edit mode if file is base64', (done) => {
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ base64: true,
+ }).then(() => {
+ expect(store.state.editMode).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('creates flash message is file already exists', (done) => {
+ store.state.tree.push(file('test', '1', 'blob'));
+
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then(() => {
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('increases level of file', (done) => {
+ store.state.level = 1;
+
+ store.dispatch('createTempFile', {
+ tree: store.state,
+ name: 'test',
+ }).then((f) => {
+ expect(f.level).toBe(2);
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/actions/tree_spec.js b/spec/javascripts/repo/stores/actions/tree_spec.js
new file mode 100644
index 00000000000..393a797c6a3
--- /dev/null
+++ b/spec/javascripts/repo/stores/actions/tree_spec.js
@@ -0,0 +1,469 @@
+import Vue from 'vue';
+import store from '~/repo/stores';
+import service from '~/repo/services';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file store tree actions', () => {
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('getTreeData', () => {
+ beforeEach(() => {
+ spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({
+ headers: {
+ 'page-title': 'test',
+ },
+ json: () => Promise.resolve({
+ last_commit_path: 'last_commit_path',
+ parent_tree_url: 'parent_tree_url',
+ path: '/',
+ trees: [{ name: 'tree' }],
+ blobs: [{ name: 'blob' }],
+ submodules: [{ name: 'submodule' }],
+ }),
+ }));
+ spyOn(history, 'pushState');
+
+ Object.assign(store.state.endpoints, {
+ rootEndpoint: 'rootEndpoint',
+ });
+ });
+
+ it('calls service getTreeData', (done) => {
+ store.dispatch('getTreeData')
+ .then(() => {
+ expect(service.getTreeData).toHaveBeenCalledWith('rootEndpoint');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('adds data into tree', (done) => {
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.tree.length).toBe(3);
+ expect(store.state.tree[0].type).toBe('tree');
+ expect(store.state.tree[1].type).toBe('submodule');
+ expect(store.state.tree[2].type).toBe('blob');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets parent tree URL', (done) => {
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.parentTreeUrl).toBe('parent_tree_url');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets last commit path', (done) => {
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.lastCommitPath).toBe('last_commit_path');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets root if not currently at root', (done) => {
+ store.state.isInitialRoot = false;
+
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.isInitialRoot).toBeTruthy();
+ expect(store.state.isRoot).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets page title', (done) => {
+ store.dispatch('getTreeData')
+ .then(() => {
+ expect(document.title).toBe('test');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('toggles loading', (done) => {
+ store.dispatch('getTreeData')
+ .then(() => {
+ expect(store.state.loading).toBeTruthy();
+
+ return Vue.nextTick();
+ })
+ .then(() => {
+ expect(store.state.loading).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls pushState with endpoint', (done) => {
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'rootEndpoint');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls getLastCommitData if prevLastCommitPath is not null', (done) => {
+ const getLastCommitDataSpy = jasmine.createSpy('getLastCommitData');
+ const oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line
+ store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line
+ store.state.prevLastCommitPath = 'test';
+
+ store.dispatch('getTreeData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(getLastCommitDataSpy).toHaveBeenCalledWith(store.state);
+
+ store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('toggleTreeOpen', () => {
+ let oldGetTreeData;
+ let getTreeDataSpy;
+ let tree;
+
+ beforeEach(() => {
+ getTreeDataSpy = jasmine.createSpy('getTreeData');
+
+ oldGetTreeData = store._actions.getTreeData; // eslint-disable-line
+ store._actions.getTreeData = [getTreeDataSpy]; // eslint-disable-line
+
+ tree = {
+ opened: false,
+ tree: [],
+ };
+ });
+
+ afterEach(() => {
+ store._actions.getTreeData = oldGetTreeData; // eslint-disable-line
+ });
+
+ it('toggles the tree open', (done) => {
+ store.dispatch('toggleTreeOpen', {
+ endpoint: 'test',
+ tree,
+ }).then(() => {
+ expect(tree.opened).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls getTreeData if tree is closed', (done) => {
+ store.dispatch('toggleTreeOpen', {
+ endpoint: 'test',
+ tree,
+ }).then(() => {
+ expect(getTreeDataSpy).toHaveBeenCalledWith({
+ endpoint: 'test',
+ tree,
+ });
+ expect(store.state.previousUrl).toBe('test');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('resets entries tree', (done) => {
+ Object.assign(tree, {
+ opened: true,
+ tree: ['a'],
+ });
+
+ store.dispatch('toggleTreeOpen', {
+ endpoint: 'test',
+ tree,
+ }).then(() => {
+ expect(tree.tree.length).toBe(0);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('pushes new state', (done) => {
+ spyOn(history, 'pushState');
+ Object.assign(tree, {
+ opened: true,
+ parentTreeUrl: 'testing',
+ });
+
+ store.dispatch('toggleTreeOpen', {
+ endpoint: 'test',
+ tree,
+ }).then(() => {
+ expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'testing');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('clickedTreeRow', () => {
+ describe('tree', () => {
+ let toggleTreeOpenSpy;
+ let oldToggleTreeOpen;
+
+ beforeEach(() => {
+ toggleTreeOpenSpy = jasmine.createSpy('toggleTreeOpen');
+
+ oldToggleTreeOpen = store._actions.toggleTreeOpen; // eslint-disable-line
+ store._actions.toggleTreeOpen = [toggleTreeOpenSpy]; // eslint-disable-line
+ });
+
+ afterEach(() => {
+ store._actions.toggleTreeOpen = oldToggleTreeOpen; // eslint-disable-line
+ });
+
+ it('opens tree', (done) => {
+ const tree = {
+ url: 'a',
+ type: 'tree',
+ };
+
+ store.dispatch('clickedTreeRow', tree)
+ .then(() => {
+ expect(toggleTreeOpenSpy).toHaveBeenCalledWith({
+ endpoint: tree.url,
+ tree,
+ });
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('submodule', () => {
+ let row;
+
+ beforeEach(() => {
+ spyOn(gl.utils, 'visitUrl');
+
+ row = {
+ url: 'submoduleurl',
+ type: 'submodule',
+ loading: false,
+ };
+ });
+
+ it('toggles loading for row', (done) => {
+ store.dispatch('clickedTreeRow', row)
+ .then(() => {
+ expect(row.loading).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('opens submodule URL', (done) => {
+ store.dispatch('clickedTreeRow', row)
+ .then(() => {
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith('submoduleurl');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('blob', () => {
+ let row;
+
+ beforeEach(() => {
+ row = {
+ type: 'blob',
+ opened: false,
+ };
+ });
+
+ it('calls getFileData', (done) => {
+ const getFileDataSpy = jasmine.createSpy('getFileData');
+ const oldGetFileData = store._actions.getFileData; // eslint-disable-line
+ store._actions.getFileData = [getFileDataSpy]; // eslint-disable-line
+
+ store.dispatch('clickedTreeRow', row)
+ .then(() => {
+ expect(getFileDataSpy).toHaveBeenCalledWith(row);
+
+ store._actions.getFileData = oldGetFileData; // eslint-disable-line
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('calls setFileActive when file is opened', (done) => {
+ const setFileActiveSpy = jasmine.createSpy('setFileActive');
+ const oldSetFileActive = store._actions.setFileActive; // eslint-disable-line
+ store._actions.setFileActive = [setFileActiveSpy]; // eslint-disable-line
+
+ row.opened = true;
+
+ store.dispatch('clickedTreeRow', row)
+ .then(() => {
+ expect(setFileActiveSpy).toHaveBeenCalledWith(row);
+
+ store._actions.setFileActive = oldSetFileActive; // eslint-disable-line
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+ });
+
+ describe('createTempTree', () => {
+ it('creates temp tree', (done) => {
+ store.dispatch('createTempTree', 'test')
+ .then(() => {
+ expect(store.state.tree[0].tempFile).toBeTruthy();
+ expect(store.state.tree[0].name).toBe('test');
+ expect(store.state.tree[0].type).toBe('tree');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('creates .gitkeep file in temp tree', (done) => {
+ store.dispatch('createTempTree', 'test')
+ .then(() => {
+ expect(store.state.tree[0].tree[0].tempFile).toBeTruthy();
+ expect(store.state.tree[0].tree[0].name).toBe('.gitkeep');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('creates new folder inside another tree', (done) => {
+ const tree = {
+ type: 'tree',
+ name: 'testing',
+ tree: [],
+ };
+
+ store.state.tree.push(tree);
+
+ store.dispatch('createTempTree', 'testing/test')
+ .then(() => {
+ expect(store.state.tree[0].name).toBe('testing');
+ expect(store.state.tree[0].tree[0].tempFile).toBeTruthy();
+ expect(store.state.tree[0].tree[0].name).toBe('test');
+ expect(store.state.tree[0].tree[0].type).toBe('tree');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('does not create new tree if already exists', (done) => {
+ const tree = {
+ type: 'tree',
+ name: 'testing',
+ tree: [],
+ };
+
+ store.state.tree.push(tree);
+
+ store.dispatch('createTempTree', 'testing/test')
+ .then(() => {
+ expect(store.state.tree[0].name).toBe('testing');
+ expect(store.state.tree[0].tempFile).toBeUndefined();
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('getLastCommitData', () => {
+ beforeEach(() => {
+ spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({
+ headers: {
+ 'more-logs-url': null,
+ },
+ json: () => Promise.resolve([{
+ type: 'tree',
+ file_name: 'testing',
+ commit: {
+ message: 'commit message',
+ authored_date: '123',
+ },
+ }]),
+ }));
+
+ store.state.tree.push(file('testing', '1', 'tree'));
+ store.state.lastCommitPath = 'lastcommitpath';
+ });
+
+ it('calls service with lastCommitPath', (done) => {
+ store.dispatch('getLastCommitData')
+ .then(() => {
+ expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('updates trees last commit data', (done) => {
+ store.dispatch('getLastCommitData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.tree[0].lastCommit.message).toBe('commit message');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('does not update entry if not found', (done) => {
+ store.state.tree[0].name = 'a';
+
+ store.dispatch('getLastCommitData')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.tree[0].lastCommit.message).not.toBe('commit message');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('updateDirectoryData', () => {
+ it('adds data into tree', (done) => {
+ const tree = {
+ tree: [],
+ };
+ const data = {
+ trees: [{ name: 'tree' }],
+ submodules: [{ name: 'submodule' }],
+ blobs: [{ name: 'blob' }],
+ };
+
+ store.dispatch('updateDirectoryData', {
+ data,
+ tree,
+ }).then(() => {
+ expect(tree.tree[0].name).toBe('tree');
+ expect(tree.tree[0].type).toBe('tree');
+ expect(tree.tree[1].name).toBe('submodule');
+ expect(tree.tree[1].type).toBe('submodule');
+ expect(tree.tree[2].name).toBe('blob');
+ expect(tree.tree[2].type).toBe('blob');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/actions_spec.js b/spec/javascripts/repo/stores/actions_spec.js
new file mode 100644
index 00000000000..f2a7a698912
--- /dev/null
+++ b/spec/javascripts/repo/stores/actions_spec.js
@@ -0,0 +1,419 @@
+import Vue from 'vue';
+import store from '~/repo/stores';
+import service from '~/repo/services';
+import { resetStore, file } from '../helpers';
+
+describe('Multi-file store actions', () => {
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('redirectToUrl', () => {
+ it('calls visitUrl', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+
+ store.dispatch('redirectToUrl', 'test')
+ .then(() => {
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith('test');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('setInitialData', () => {
+ it('commits initial data', (done) => {
+ store.dispatch('setInitialData', { canCommit: true })
+ .then(() => {
+ expect(store.state.canCommit).toBeTruthy();
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('closeDiscardPopup', () => {
+ it('closes the discard popup', (done) => {
+ store.dispatch('closeDiscardPopup', false)
+ .then(() => {
+ expect(store.state.discardPopupOpen).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardAllChanges', () => {
+ beforeEach(() => {
+ store.state.openFiles.push(file());
+ store.state.openFiles[0].changed = true;
+ });
+ });
+
+ describe('closeAllFiles', () => {
+ beforeEach(() => {
+ store.state.openFiles.push(file());
+ store.state.openFiles[0].opened = true;
+ });
+
+ it('closes all open files', (done) => {
+ store.dispatch('closeAllFiles')
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('toggleEditMode', () => {
+ it('toggles edit mode', (done) => {
+ store.state.editMode = true;
+
+ store.dispatch('toggleEditMode')
+ .then(() => {
+ expect(store.state.editMode).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('sets preview mode', (done) => {
+ store.state.currentBlobView = 'repo-editor';
+ store.state.editMode = true;
+
+ store.dispatch('toggleEditMode')
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.currentBlobView).toBe('repo-preview');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('opens discard popup if there are changed files', (done) => {
+ store.state.editMode = true;
+ store.state.openFiles.push(file());
+ store.state.openFiles[0].changed = true;
+
+ store.dispatch('toggleEditMode')
+ .then(() => {
+ expect(store.state.discardPopupOpen).toBeTruthy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('can force closed if there are changed files', (done) => {
+ store.state.editMode = true;
+ store.state.openFiles.push(file());
+ store.state.openFiles[0].changed = true;
+
+ store.dispatch('toggleEditMode', true)
+ .then(() => {
+ expect(store.state.discardPopupOpen).toBeFalsy();
+ expect(store.state.editMode).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('discards file changes', (done) => {
+ const f = file();
+ store.state.editMode = true;
+ store.state.tree.push(f);
+ store.state.openFiles.push(f);
+ f.changed = true;
+
+ store.dispatch('toggleEditMode', true)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(f.changed).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('toggleBlobView', () => {
+ it('sets edit mode view if in edit mode', (done) => {
+ store.state.editMode = true;
+
+ store.dispatch('toggleBlobView')
+ .then(() => {
+ expect(store.state.currentBlobView).toBe('repo-editor');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets preview mode view if not in edit mode', (done) => {
+ store.dispatch('toggleBlobView')
+ .then(() => {
+ expect(store.state.currentBlobView).toBe('repo-preview');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('checkCommitStatus', () => {
+ beforeEach(() => {
+ store.state.project.id = 2;
+ store.state.currentBranch = 'master';
+ store.state.currentRef = '1';
+ });
+
+ it('calls service', (done) => {
+ spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
+ commit: { id: '123' },
+ }));
+
+ store.dispatch('checkCommitStatus')
+ .then(() => {
+ expect(service.getBranchData).toHaveBeenCalledWith(2, 'master');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns true if current ref does not equal returned ID', (done) => {
+ spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
+ commit: { id: '123' },
+ }));
+
+ store.dispatch('checkCommitStatus')
+ .then((val) => {
+ expect(val).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns false if current ref equals returned ID', (done) => {
+ spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({
+ commit: { id: '1' },
+ }));
+
+ store.dispatch('checkCommitStatus')
+ .then((val) => {
+ expect(val).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('commitChanges', () => {
+ let payload;
+
+ beforeEach(() => {
+ spyOn(window, 'scrollTo');
+
+ document.body.innerHTML += '<div class="flash-container"></div>';
+
+ store.state.project.id = 123;
+ payload = {
+ branch: 'master',
+ };
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(Promise.resolve({
+ id: '123456',
+ short_id: '123',
+ message: 'test message',
+ committed_date: 'date',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ }));
+ });
+
+ it('calls service', (done) => {
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ expect(service.commit).toHaveBeenCalledWith(123, payload);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('shows flash notice', (done) => {
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ const alert = document.querySelector('.flash-container');
+
+ expect(alert.querySelector('.flash-notice')).not.toBeNull();
+ expect(alert.textContent.trim()).toBe(
+ 'Your changes have been committed. Commit 123 with 1 additions, 2 deletions.',
+ );
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('adds commit data to changed files', (done) => {
+ const changedFile = file();
+ const f = file();
+ changedFile.changed = true;
+
+ store.state.openFiles.push(changedFile, f);
+
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ expect(changedFile.lastCommit.message).toBe('test message');
+ expect(f.lastCommit.message).not.toBe('test message');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('toggles edit mode', (done) => {
+ store.state.editMode = true;
+
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ expect(store.state.editMode).toBeFalsy();
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('closes all files', (done) => {
+ store.state.openFiles.push(file());
+ store.state.openFiles[0].opened = true;
+
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('scrolls to top of page', (done) => {
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('updates commit ref', (done) => {
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ expect(store.state.currentRef).toBe('123456');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('redirects to new merge request page', (done) => {
+ spyOn(gl.utils, 'visitUrl');
+
+ store.state.endpoints.newMergeRequestUrl = 'newMergeRequestUrl?branch=';
+
+ store.dispatch('commitChanges', { payload, newMr: true })
+ .then(() => {
+ expect(gl.utils.visitUrl).toHaveBeenCalledWith('newMergeRequestUrl?branch=master');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('failed', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(Promise.resolve({
+ message: 'failed message',
+ }));
+ });
+
+ it('shows failed message', (done) => {
+ store.dispatch('commitChanges', { payload, newMr: false })
+ .then(() => {
+ const alert = document.querySelector('.flash-container');
+
+ expect(alert.textContent.trim()).toBe(
+ 'failed message',
+ );
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+ });
+
+ describe('createTempEntry', () => {
+ it('creates a temp tree', (done) => {
+ store.dispatch('createTempEntry', {
+ name: 'test',
+ type: 'tree',
+ })
+ .then(() => {
+ expect(store.state.tree.length).toBe(1);
+ expect(store.state.tree[0].tempFile).toBeTruthy();
+ expect(store.state.tree[0].type).toBe('tree');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('creates temp file', (done) => {
+ store.dispatch('createTempEntry', {
+ name: 'test',
+ type: 'blob',
+ })
+ .then(() => {
+ expect(store.state.tree.length).toBe(1);
+ expect(store.state.tree[0].tempFile).toBeTruthy();
+ expect(store.state.tree[0].type).toBe('blob');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('popHistoryState', () => {
+
+ });
+
+ describe('scrollToTab', () => {
+ it('focuses the current active element', (done) => {
+ document.body.innerHTML += '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>';
+ const el = document.querySelector('.repo-tab');
+ spyOn(el, 'focus');
+
+ store.dispatch('scrollToTab')
+ .then(() => {
+ setTimeout(() => {
+ expect(el.focus).toHaveBeenCalled();
+
+ document.getElementById('tabs').remove();
+
+ done();
+ });
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/getters_spec.js b/spec/javascripts/repo/stores/getters_spec.js
new file mode 100644
index 00000000000..a204b2386cd
--- /dev/null
+++ b/spec/javascripts/repo/stores/getters_spec.js
@@ -0,0 +1,119 @@
+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();
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/mutations/branch_spec.js b/spec/javascripts/repo/stores/mutations/branch_spec.js
new file mode 100644
index 00000000000..3c06794d5e3
--- /dev/null
+++ b/spec/javascripts/repo/stores/mutations/branch_spec.js
@@ -0,0 +1,18 @@
+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');
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/mutations/file_spec.js b/spec/javascripts/repo/stores/mutations/file_spec.js
new file mode 100644
index 00000000000..2f2835dde1f
--- /dev/null
+++ b/spec/javascripts/repo/stores/mutations/file_spec.js
@@ -0,0 +1,131 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/mutations/tree_spec.js b/spec/javascripts/repo/stores/mutations/tree_spec.js
new file mode 100644
index 00000000000..1c76cfed9c8
--- /dev/null
+++ b/spec/javascripts/repo/stores/mutations/tree_spec.js
@@ -0,0 +1,71 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/mutations_spec.js b/spec/javascripts/repo/stores/mutations_spec.js
new file mode 100644
index 00000000000..d1c9885e01d
--- /dev/null
+++ b/spec/javascripts/repo/stores/mutations_spec.js
@@ -0,0 +1,117 @@
+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');
+ });
+ });
+});
diff --git a/spec/javascripts/repo/stores/utils_spec.js b/spec/javascripts/repo/stores/utils_spec.js
new file mode 100644
index 00000000000..37287c587d7
--- /dev/null
+++ b/spec/javascripts/repo/stores/utils_spec.js
@@ -0,0 +1,102 @@
+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();
+ });
+ });
+});
diff --git a/spec/lib/gitlab/utils/strong_memoize_spec.rb b/spec/lib/gitlab/utils/strong_memoize_spec.rb
new file mode 100644
index 00000000000..4a104ab6d97
--- /dev/null
+++ b/spec/lib/gitlab/utils/strong_memoize_spec.rb
@@ -0,0 +1,52 @@
+require 'spec_helper'
+
+describe Gitlab::Utils::StrongMemoize do
+ let(:klass) do
+ struct = Struct.new(:value) do
+ def method_name
+ strong_memoize(:method_name) do
+ trace << value
+ value
+ end
+ end
+
+ def trace
+ @trace ||= []
+ end
+ end
+
+ struct.include(described_class)
+ struct
+ end
+
+ subject(:object) { klass.new(value) }
+
+ shared_examples 'caching the value' do
+ it 'only calls the block once' do
+ value0 = object.method_name
+ value1 = object.method_name
+
+ expect(value0).to eq(value)
+ expect(value1).to eq(value)
+ expect(object.trace).to contain_exactly(value)
+ end
+
+ it 'returns and defines the instance variable for the exact value' do
+ returned_value = object.method_name
+ memoized_value = object.instance_variable_get(:@method_name)
+
+ expect(returned_value).to eql(value)
+ expect(memoized_value).to eql(value)
+ end
+ end
+
+ describe '#strong_memoize' do
+ [nil, false, true, 'value', 0, [0]].each do |value|
+ context "with value #{value}" do
+ let(:value) { value }
+
+ it_behaves_like 'caching the value'
+ end
+ end
+ end
+end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index c832cee965b..f942a22b6d1 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -783,7 +783,25 @@ describe Notify do
shared_examples 'an email for a note on a diff discussion' do |model|
let(:note) { create(model, author: note_author) }
- it "includes diffs with character-level highlighting" do
+ context 'when note is on image' do
+ before do
+ allow_any_instance_of(DiffDiscussion).to receive(:on_image?).and_return(true)
+ end
+
+ it 'does not include diffs with character-level highlighting' do
+ is_expected.not_to have_body_text '<span class="p">}</span></span>'
+ end
+
+ it 'ends the intro with a dot' do
+ is_expected.to have_body_text "#{note.diff_file.file_path}</a>."
+ end
+ end
+
+ it 'ends the intro with a colon' do
+ is_expected.to have_body_text "#{note.diff_file.file_path}</a>:"
+ end
+
+ it 'includes diffs with character-level highlighting' do
is_expected.to have_body_text '<span class="p">}</span></span>'
end
diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb
index 66353935427..9048da0c73d 100644
--- a/spec/models/concerns/milestoneish_spec.rb
+++ b/spec/models/concerns/milestoneish_spec.rb
@@ -186,4 +186,21 @@ describe Milestone, 'Milestoneish' do
expect(milestone.elapsed_days).to eq(2)
end
end
+
+ describe '#total_issue_time_spent' do
+ it 'calculates total issue time spent' do
+ closed_issue_1.spend_time(duration: 300, user: author)
+ closed_issue_1.save!
+ closed_issue_2.spend_time(duration: 600, user: assignee)
+ closed_issue_2.save!
+
+ expect(milestone.total_issue_time_spent).to eq(900)
+ end
+ end
+
+ describe '#human_total_issue_time_spent' do
+ it 'returns nil if no time has been spent' do
+ expect(milestone.human_total_issue_time_spent).to be_nil
+ end
+ end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index bb5033c1628..5f901262598 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -765,22 +765,4 @@ describe Issue do
expect(described_class.public_only).to eq([public_issue])
end
end
-
- describe '#update_project_counter_caches?' do
- it 'returns true when the state changes' do
- subject.state = 'closed'
-
- expect(subject.update_project_counter_caches?).to eq(true)
- end
-
- it 'returns true when the confidential flag changes' do
- subject.confidential = true
-
- expect(subject.update_project_counter_caches?).to eq(true)
- end
-
- it 'returns false when the state or confidential flag did not change' do
- expect(subject.update_project_counter_caches?).to eq(false)
- end
- end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index d022dae3476..d250ad50713 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1772,16 +1772,4 @@ describe MergeRequest do
.to change { project.open_merge_requests_count }.from(1).to(0)
end
end
-
- describe '#update_project_counter_caches?' do
- it 'returns true when the state changes' do
- subject.state = 'closed'
-
- expect(subject.update_project_counter_caches?).to eq(true)
- end
-
- it 'returns false when the state did not change' do
- expect(subject.update_project_counter_caches?).to eq(false)
- end
- end
end
diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb
index d13b3a958c9..d412b045e9f 100644
--- a/spec/requests/api/pages_domains_spec.rb
+++ b/spec/requests/api/pages_domains_spec.rb
@@ -3,6 +3,7 @@ require 'rails_helper'
describe API::PagesDomains do
set(:project) { create(:project) }
set(:user) { create(:user) }
+ set(:admin) { create(:admin) }
set(:pages_domain) { create(:pages_domain, domain: 'www.domain.test', project: project) }
set(:pages_domain_secure) { create(:pages_domain, :with_certificate, :with_key, domain: 'ssl.domain.test', project: project) }
@@ -23,12 +24,49 @@ describe API::PagesDomains do
allow(Gitlab.config.pages).to receive(:enabled).and_return(true)
end
+ describe 'GET /pages/domains' do
+ context 'when pages is disabled' do
+ before do
+ allow(Gitlab.config.pages).to receive(:enabled).and_return(false)
+ end
+
+ it_behaves_like '404 response' do
+ let(:request) { get api('/pages/domains', admin) }
+ end
+ end
+
+ context 'when pages is enabled' do
+ context 'when authenticated as an admin' do
+ it 'returns paginated all pages domains' do
+ get api('/pages/domains', admin)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain_basics')
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(3)
+ expect(json_response.last).to have_key('domain')
+ expect(json_response.last).to have_key('certificate_expiration')
+ expect(json_response.last['certificate_expiration']['expired']).to be true
+ expect(json_response.first).not_to have_key('certificate_expiration')
+ end
+ end
+
+ context 'when authenticated as a non-member' do
+ it_behaves_like '403 response' do
+ let(:request) { get api('/pages/domains', user) }
+ end
+ end
+ end
+ end
+
describe 'GET /projects/:project_id/pages/domains' do
shared_examples_for 'get pages domains' do
it 'returns paginated pages domains' do
get api(route, user)
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domains')
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(3)
@@ -99,6 +137,7 @@ describe API::PagesDomains do
get api(route_domain, user)
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(json_response['domain']).to eq(pages_domain.domain)
expect(json_response['url']).to eq(pages_domain.url)
expect(json_response['certificate']).to be_nil
@@ -108,6 +147,7 @@ describe API::PagesDomains do
get api(route_secure_domain, user)
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(json_response['domain']).to eq(pages_domain_secure.domain)
expect(json_response['url']).to eq(pages_domain_secure.url)
expect(json_response['certificate']['subject']).to eq(pages_domain_secure.subject)
@@ -118,6 +158,7 @@ describe API::PagesDomains do
get api(route_expired_domain, user)
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(json_response['certificate']['expired']).to be true
end
end
@@ -187,6 +228,7 @@ describe API::PagesDomains do
pages_domain = PagesDomain.find_by(domain: json_response['domain'])
expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain.domain).to eq(params[:domain])
expect(pages_domain.certificate).to be_nil
expect(pages_domain.key).to be_nil
@@ -197,6 +239,7 @@ describe API::PagesDomains do
pages_domain = PagesDomain.find_by(domain: json_response['domain'])
expect(response).to have_gitlab_http_status(201)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain.domain).to eq(params_secure[:domain])
expect(pages_domain.certificate).to eq(params_secure[:certificate])
expect(pages_domain.key).to eq(params_secure[:key])
@@ -270,6 +313,7 @@ describe API::PagesDomains do
pages_domain_secure.reload
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain_secure.certificate).to be_nil
expect(pages_domain_secure.key).to be_nil
end
@@ -279,6 +323,7 @@ describe API::PagesDomains do
pages_domain.reload
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain.certificate).to eq(params_secure[:certificate])
expect(pages_domain.key).to eq(params_secure[:key])
end
@@ -288,6 +333,7 @@ describe API::PagesDomains do
pages_domain_expired.reload
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain_expired.certificate).to eq(params_secure[:certificate])
expect(pages_domain_expired.key).to eq(params_secure[:key])
end
@@ -297,6 +343,7 @@ describe API::PagesDomains do
pages_domain_secure.reload
expect(response).to have_gitlab_http_status(200)
+ expect(response).to match_response_schema('public_api/v4/pages_domain/detail')
expect(pages_domain_secure.certificate).to eq(params_secure_nokey[:certificate])
end
diff --git a/vendor/assets/javascripts/latinise.js b/vendor/assets/javascripts/latinise.js
deleted file mode 100644
index da37966b28a..00000000000
--- a/vendor/assets/javascripts/latinise.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Converting text to basic latin (aka removing accents)
-//
-// Based on: http://semplicewebsites.com/removing-accents-javascript
-//
-var Latinise = {
- map: {"Á":"A","Ă":"A","Ắ":"A","Ặ":"A","Ằ":"A","Ẳ":"A","Ẵ":"A","Ǎ":"A","Â":"A","Ấ":"A","Ậ":"A","Ầ":"A","Ẩ":"A","Ẫ":"A","Ä":"A","Ǟ":"A","Ȧ":"A","Ǡ":"A","Ạ":"A","Ȁ":"A","À":"A","Ả":"A","Ȃ":"A","Ā":"A","Ą":"A","Å":"A","Ǻ":"A","Ḁ":"A","Ⱥ":"A","Ã":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ḃ":"B","Ḅ":"B","Ɓ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ć":"C","Č":"C","Ç":"C","Ḉ":"C","Ĉ":"C","Ċ":"C","Ƈ":"C","Ȼ":"C","Ď":"D","Ḑ":"D","Ḓ":"D","Ḋ":"D","Ḍ":"D","Ɗ":"D","Ḏ":"D","Dz":"D","Dž":"D","Đ":"D","Ƌ":"D","DZ":"DZ","DŽ":"DZ","É":"E","Ĕ":"E","Ě":"E","Ȩ":"E","Ḝ":"E","Ê":"E","Ế":"E","Ệ":"E","Ề":"E","Ể":"E","Ễ":"E","Ḙ":"E","Ë":"E","Ė":"E","Ẹ":"E","Ȅ":"E","È":"E","Ẻ":"E","Ȇ":"E","Ē":"E","Ḗ":"E","Ḕ":"E","Ę":"E","Ɇ":"E","Ẽ":"E","Ḛ":"E","Ꝫ":"ET","Ḟ":"F","Ƒ":"F","Ǵ":"G","Ğ":"G","Ǧ":"G","Ģ":"G","Ĝ":"G","Ġ":"G","Ɠ":"G","Ḡ":"G","Ǥ":"G","Ḫ":"H","Ȟ":"H","Ḩ":"H","Ĥ":"H","Ⱨ":"H","Ḧ":"H","Ḣ":"H","Ḥ":"H","Ħ":"H","Í":"I","Ĭ":"I","Ǐ":"I","Î":"I","Ï":"I","Ḯ":"I","İ":"I","Ị":"I","Ȉ":"I","Ì":"I","Ỉ":"I","Ȋ":"I","Ī":"I","Į":"I","Ɨ":"I","Ĩ":"I","Ḭ":"I","Ꝺ":"D","Ꝼ":"F","Ᵹ":"G","Ꞃ":"R","Ꞅ":"S","Ꞇ":"T","Ꝭ":"IS","Ĵ":"J","Ɉ":"J","Ḱ":"K","Ǩ":"K","Ķ":"K","Ⱪ":"K","Ꝃ":"K","Ḳ":"K","Ƙ":"K","Ḵ":"K","Ꝁ":"K","Ꝅ":"K","Ĺ":"L","Ƚ":"L","Ľ":"L","Ļ":"L","Ḽ":"L","Ḷ":"L","Ḹ":"L","Ⱡ":"L","Ꝉ":"L","Ḻ":"L","Ŀ":"L","Ɫ":"L","Lj":"L","Ł":"L","LJ":"LJ","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ń":"N","Ň":"N","Ņ":"N","Ṋ":"N","Ṅ":"N","Ṇ":"N","Ǹ":"N","Ɲ":"N","Ṉ":"N","Ƞ":"N","Nj":"N","Ñ":"N","NJ":"NJ","Ó":"O","Ŏ":"O","Ǒ":"O","Ô":"O","Ố":"O","Ộ":"O","Ồ":"O","Ổ":"O","Ỗ":"O","Ö":"O","Ȫ":"O","Ȯ":"O","Ȱ":"O","Ọ":"O","Ő":"O","Ȍ":"O","Ò":"O","Ỏ":"O","Ơ":"O","Ớ":"O","Ợ":"O","Ờ":"O","Ở":"O","Ỡ":"O","Ȏ":"O","Ꝋ":"O","Ꝍ":"O","Ō":"O","Ṓ":"O","Ṑ":"O","Ɵ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Õ":"O","Ṍ":"O","Ṏ":"O","Ȭ":"O","Ƣ":"OI","Ꝏ":"OO","Ɛ":"E","Ɔ":"O","Ȣ":"OU","Ṕ":"P","Ṗ":"P","Ꝓ":"P","Ƥ":"P","Ꝕ":"P","Ᵽ":"P","Ꝑ":"P","Ꝙ":"Q","Ꝗ":"Q","Ŕ":"R","Ř":"R","Ŗ":"R","Ṙ":"R","Ṛ":"R","Ṝ":"R","Ȑ":"R","Ȓ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꜿ":"C","Ǝ":"E","Ś":"S","Ṥ":"S","Š":"S","Ṧ":"S","Ş":"S","Ŝ":"S","Ș":"S","Ṡ":"S","Ṣ":"S","Ṩ":"S","ẞ":"SS","Ť":"T","Ţ":"T","Ṱ":"T","Ț":"T","Ⱦ":"T","Ṫ":"T","Ṭ":"T","Ƭ":"T","Ṯ":"T","Ʈ":"T","Ŧ":"T","Ɐ":"A","Ꞁ":"L","Ɯ":"M","Ʌ":"V","Ꜩ":"TZ","Ú":"U","Ŭ":"U","Ǔ":"U","Û":"U","Ṷ":"U","Ü":"U","Ǘ":"U","Ǚ":"U","Ǜ":"U","Ǖ":"U","Ṳ":"U","Ụ":"U","Ű":"U","Ȕ":"U","Ù":"U","Ủ":"U","Ư":"U","Ứ":"U","Ự":"U","Ừ":"U","Ử":"U","Ữ":"U","Ȗ":"U","Ū":"U","Ṻ":"U","Ų":"U","Ů":"U","Ũ":"U","Ṹ":"U","Ṵ":"U","Ꝟ":"V","Ṿ":"V","Ʋ":"V","Ṽ":"V","Ꝡ":"VY","Ẃ":"W","Ŵ":"W","Ẅ":"W","Ẇ":"W","Ẉ":"W","Ẁ":"W","Ⱳ":"W","Ẍ":"X","Ẋ":"X","Ý":"Y","Ŷ":"Y","Ÿ":"Y","Ẏ":"Y","Ỵ":"Y","Ỳ":"Y","Ƴ":"Y","Ỷ":"Y","Ỿ":"Y","Ȳ":"Y","Ɏ":"Y","Ỹ":"Y","Ź":"Z","Ž":"Z","Ẑ":"Z","Ⱬ":"Z","Ż":"Z","Ẓ":"Z","Ȥ":"Z","Ẕ":"Z","Ƶ":"Z","IJ":"IJ","Œ":"OE","ᴀ":"A","ᴁ":"AE","ʙ":"B","ᴃ":"B","ᴄ":"C","ᴅ":"D","ᴇ":"E","ꜰ":"F","ɢ":"G","ʛ":"G","ʜ":"H","ɪ":"I","ʁ":"R","ᴊ":"J","ᴋ":"K","ʟ":"L","ᴌ":"L","ᴍ":"M","ɴ":"N","ᴏ":"O","ɶ":"OE","ᴐ":"O","ᴕ":"OU","ᴘ":"P","ʀ":"R","ᴎ":"N","ᴙ":"R","ꜱ":"S","ᴛ":"T","ⱻ":"E","ᴚ":"R","ᴜ":"U","ᴠ":"V","ᴡ":"W","ʏ":"Y","ᴢ":"Z","á":"a","ă":"a","ắ":"a","ặ":"a","ằ":"a","ẳ":"a","ẵ":"a","ǎ":"a","â":"a","ấ":"a","ậ":"a","ầ":"a","ẩ":"a","ẫ":"a","ä":"a","ǟ":"a","ȧ":"a","ǡ":"a","ạ":"a","ȁ":"a","à":"a","ả":"a","ȃ":"a","ā":"a","ą":"a","ᶏ":"a","ẚ":"a","å":"a","ǻ":"a","ḁ":"a","ⱥ":"a","ã":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ḃ":"b","ḅ":"b","ɓ":"b","ḇ":"b","ᵬ":"b","ᶀ":"b","ƀ":"b","ƃ":"b","ɵ":"o","ć":"c","č":"c","ç":"c","ḉ":"c","ĉ":"c","ɕ":"c","ċ":"c","ƈ":"c","ȼ":"c","ď":"d","ḑ":"d","ḓ":"d","ȡ":"d","ḋ":"d","ḍ":"d","ɗ":"d","ᶑ":"d","ḏ":"d","ᵭ":"d","ᶁ":"d","đ":"d","ɖ":"d","ƌ":"d","ı":"i","ȷ":"j","ɟ":"j","ʄ":"j","dz":"dz","dž":"dz","é":"e","ĕ":"e","ě":"e","ȩ":"e","ḝ":"e","ê":"e","ế":"e","ệ":"e","ề":"e","ể":"e","ễ":"e","ḙ":"e","ë":"e","ė":"e","ẹ":"e","ȅ":"e","è":"e","ẻ":"e","ȇ":"e","ē":"e","ḗ":"e","ḕ":"e","ⱸ":"e","ę":"e","ᶒ":"e","ɇ":"e","ẽ":"e","ḛ":"e","ꝫ":"et","ḟ":"f","ƒ":"f","ᵮ":"f","ᶂ":"f","ǵ":"g","ğ":"g","ǧ":"g","ģ":"g","ĝ":"g","ġ":"g","ɠ":"g","ḡ":"g","ᶃ":"g","ǥ":"g","ḫ":"h","ȟ":"h","ḩ":"h","ĥ":"h","ⱨ":"h","ḧ":"h","ḣ":"h","ḥ":"h","ɦ":"h","ẖ":"h","ħ":"h","ƕ":"hv","í":"i","ĭ":"i","ǐ":"i","î":"i","ï":"i","ḯ":"i","ị":"i","ȉ":"i","ì":"i","ỉ":"i","ȋ":"i","ī":"i","į":"i","ᶖ":"i","ɨ":"i","ĩ":"i","ḭ":"i","ꝺ":"d","ꝼ":"f","ᵹ":"g","ꞃ":"r","ꞅ":"s","ꞇ":"t","ꝭ":"is","ǰ":"j","ĵ":"j","ʝ":"j","ɉ":"j","ḱ":"k","ǩ":"k","ķ":"k","ⱪ":"k","ꝃ":"k","ḳ":"k","ƙ":"k","ḵ":"k","ᶄ":"k","ꝁ":"k","ꝅ":"k","ĺ":"l","ƚ":"l","ɬ":"l","ľ":"l","ļ":"l","ḽ":"l","ȴ":"l","ḷ":"l","ḹ":"l","ⱡ":"l","ꝉ":"l","ḻ":"l","ŀ":"l","ɫ":"l","ᶅ":"l","ɭ":"l","ł":"l","lj":"lj","ſ":"s","ẜ":"s","ẛ":"s","ẝ":"s","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ᵯ":"m","ᶆ":"m","ń":"n","ň":"n","ņ":"n","ṋ":"n","ȵ":"n","ṅ":"n","ṇ":"n","ǹ":"n","ɲ":"n","ṉ":"n","ƞ":"n","ᵰ":"n","ᶇ":"n","ɳ":"n","ñ":"n","nj":"nj","ó":"o","ŏ":"o","ǒ":"o","ô":"o","ố":"o","ộ":"o","ồ":"o","ổ":"o","ỗ":"o","ö":"o","ȫ":"o","ȯ":"o","ȱ":"o","ọ":"o","ő":"o","ȍ":"o","ò":"o","ỏ":"o","ơ":"o","ớ":"o","ợ":"o","ờ":"o","ở":"o","ỡ":"o","ȏ":"o","ꝋ":"o","ꝍ":"o","ⱺ":"o","ō":"o","ṓ":"o","ṑ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","õ":"o","ṍ":"o","ṏ":"o","ȭ":"o","ƣ":"oi","ꝏ":"oo","ɛ":"e","ᶓ":"e","ɔ":"o","ᶗ":"o","ȣ":"ou","ṕ":"p","ṗ":"p","ꝓ":"p","ƥ":"p","ᵱ":"p","ᶈ":"p","ꝕ":"p","ᵽ":"p","ꝑ":"p","ꝙ":"q","ʠ":"q","ɋ":"q","ꝗ":"q","ŕ":"r","ř":"r","ŗ":"r","ṙ":"r","ṛ":"r","ṝ":"r","ȑ":"r","ɾ":"r","ᵳ":"r","ȓ":"r","ṟ":"r","ɼ":"r","ᵲ":"r","ᶉ":"r","ɍ":"r","ɽ":"r","ↄ":"c","ꜿ":"c","ɘ":"e","ɿ":"r","ś":"s","ṥ":"s","š":"s","ṧ":"s","ş":"s","ŝ":"s","ș":"s","ṡ":"s","ṣ":"s","ṩ":"s","ʂ":"s","ᵴ":"s","ᶊ":"s","ȿ":"s","ɡ":"g","ß":"ss","ᴑ":"o","ᴓ":"o","ᴝ":"u","ť":"t","ţ":"t","ṱ":"t","ț":"t","ȶ":"t","ẗ":"t","ⱦ":"t","ṫ":"t","ṭ":"t","ƭ":"t","ṯ":"t","ᵵ":"t","ƫ":"t","ʈ":"t","ŧ":"t","ᵺ":"th","ɐ":"a","ᴂ":"ae","ǝ":"e","ᵷ":"g","ɥ":"h","ʮ":"h","ʯ":"h","ᴉ":"i","ʞ":"k","ꞁ":"l","ɯ":"m","ɰ":"m","ᴔ":"oe","ɹ":"r","ɻ":"r","ɺ":"r","ⱹ":"r","ʇ":"t","ʌ":"v","ʍ":"w","ʎ":"y","ꜩ":"tz","ú":"u","ŭ":"u","ǔ":"u","û":"u","ṷ":"u","ü":"u","ǘ":"u","ǚ":"u","ǜ":"u","ǖ":"u","ṳ":"u","ụ":"u","ű":"u","ȕ":"u","ù":"u","ủ":"u","ư":"u","ứ":"u","ự":"u","ừ":"u","ử":"u","ữ":"u","ȗ":"u","ū":"u","ṻ":"u","ų":"u","ᶙ":"u","ů":"u","ũ":"u","ṹ":"u","ṵ":"u","ᵫ":"ue","ꝸ":"um","ⱴ":"v","ꝟ":"v","ṿ":"v","ʋ":"v","ᶌ":"v","ⱱ":"v","ṽ":"v","ꝡ":"vy","ẃ":"w","ŵ":"w","ẅ":"w","ẇ":"w","ẉ":"w","ẁ":"w","ⱳ":"w","ẘ":"w","ẍ":"x","ẋ":"x","ᶍ":"x","ý":"y","ŷ":"y","ÿ":"y","ẏ":"y","ỵ":"y","ỳ":"y","ƴ":"y","ỷ":"y","ỿ":"y","ȳ":"y","ẙ":"y","ɏ":"y","ỹ":"y","ź":"z","ž":"z","ẑ":"z","ʑ":"z","ⱬ":"z","ż":"z","ẓ":"z","ȥ":"z","ẕ":"z","ᵶ":"z","ᶎ":"z","ʐ":"z","ƶ":"z","ɀ":"z","ff":"ff","ffi":"ffi","ffl":"ffl","fi":"fi","fl":"fl","ij":"ij","œ":"oe","st":"st","ₐ":"a","ₑ":"e","ᵢ":"i","ⱼ":"j","ₒ":"o","ᵣ":"r","ᵤ":"u","ᵥ":"v","ₓ":"x"}
-};
-
-String.prototype.latinise = function() {
- return this.replace(/[^A-Za-z0-9]/g, function(x) { return Latinise.map[x] || x; });
-};