Commit 159556b1 authored by Ruben Davila's avatar Ruben Davila

Merge remote-tracking branch 'ce/8-11-stable' into 8-11-stable-ee

parents 59ac8912 bce14db6
...@@ -10,6 +10,7 @@ v 8.11.0 (unreleased) ...@@ -10,6 +10,7 @@ v 8.11.0 (unreleased)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz) - Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948 - Update to Ruby 2.3.1. !4948
- Add Issues Board !5548 - Add Issues Board !5548
- Allow resolving merge conflicts in the UI !5479
- Improve diff performance by eliminating redundant checks for text blobs - Improve diff performance by eliminating redundant checks for text blobs
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi) - Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps) - Convert switch icon into icon font (ClemMakesApps)
...@@ -19,6 +20,7 @@ v 8.11.0 (unreleased) ...@@ -19,6 +20,7 @@ v 8.11.0 (unreleased)
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
- GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository - GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
- Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
- Allow naming U2F devices !5833
- Ignore URLs starting with // in Markdown links !5677 (winniehell) - Ignore URLs starting with // in Markdown links !5677 (winniehell)
- Fix CI status icon link underline (ClemMakesApps) - Fix CI status icon link underline (ClemMakesApps)
- The Repository class is now instrumented - The Repository class is now instrumented
...@@ -27,6 +29,9 @@ v 8.11.0 (unreleased) ...@@ -27,6 +29,9 @@ v 8.11.0 (unreleased)
- Expand commit message width in repo view (ClemMakesApps) - Expand commit message width in repo view (ClemMakesApps)
- Cache highlighted diff lines for merge requests - Cache highlighted diff lines for merge requests
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295 - Pre-create all builds for a Pipeline when the new Pipeline is created !5295
- Allow merge request diff notes and discussions to be explicitly marked as resolved
- API: Add deployment endpoints
- API: Add Play endpoint on Builds
- Fix of 'Commits being passed to custom hooks are already reachable when using the UI' - Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
- Show member roles to all users on members page - Show member roles to all users on members page
- Project.visible_to_user is instrumented again - Project.visible_to_user is instrumented again
...@@ -43,12 +48,18 @@ v 8.11.0 (unreleased) ...@@ -43,12 +48,18 @@ v 8.11.0 (unreleased)
- Various redundant database indexes have been removed - Various redundant database indexes have been removed
- Update `timeago` plugin to use multiple string/locale settings - Update `timeago` plugin to use multiple string/locale settings
- Remove unused images (ClemMakesApps) - Remove unused images (ClemMakesApps)
- Get issue and merge request description templates from repositories
- Add hover state to todos !5361 (winniehell)
- Fix icon alignment of star and fork buttons !5451 (winniehell)
- Enforce 2FA restrictions on API authentication endpoints !5820
- Limit git rev-list output count to one in forced push check - Limit git rev-list output count to one in forced push check
- Show deployment status on merge requests with external URLs - Show deployment status on merge requests with external URLs
- Clean up unused routes (Josef Strzibny) - Clean up unused routes (Josef Strzibny)
- Fix issue on empty project to allow developers to only push to protected branches if given permission - Fix issue on empty project to allow developers to only push to protected branches if given permission
- API: Add enpoints for pipelines
- Add green outline to New Branch button. !5447 (winniehell) - Add green outline to New Branch button. !5447 (winniehell)
- Optimize generating of cache keys for issues and notes - Optimize generating of cache keys for issues and notes
- Fix repository push email formatting in Outlook
- Improve performance of syntax highlighting Markdown code blocks - Improve performance of syntax highlighting Markdown code blocks
- Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects
- Remove delay when hitting "Reply..." button on page with a lot of discussions - Remove delay when hitting "Reply..." button on page with a lot of discussions
...@@ -57,9 +68,12 @@ v 8.11.0 (unreleased) ...@@ -57,9 +68,12 @@ v 8.11.0 (unreleased)
- Upgrade Grape from 0.13.0 to 0.15.0. !4601 - Upgrade Grape from 0.13.0 to 0.15.0. !4601
- Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries - Trigram indexes for the "ci_runners" table have been removed to speed up UPDATE queries
- Fix devise deprecation warnings. - Fix devise deprecation warnings.
- Check for 2FA when using Git over HTTP and only allow PersonalAccessTokens as password in that case !5764
- Update version_sorter and use new interface for faster tag sorting - Update version_sorter and use new interface for faster tag sorting
- Optimize checking if a user has read access to a list of issues !5370 - Optimize checking if a user has read access to a list of issues !5370
- Store all DB secrets in secrets.yml, under descriptive names !5274 - Store all DB secrets in secrets.yml, under descriptive names !5274
- Fix syntax highlighting in file editor
- Support slash commands in issue and merge request descriptions as well as comments. !5021
- Nokogiri's various parsing methods are now instrumented - Nokogiri's various parsing methods are now instrumented
- Add archived badge to project list !5798 - Add archived badge to project list !5798
- Add simple identifier to public SSH keys (muteor) - Add simple identifier to public SSH keys (muteor)
...@@ -98,6 +112,7 @@ v 8.11.0 (unreleased) ...@@ -98,6 +112,7 @@ v 8.11.0 (unreleased)
- Add commit stats in commit api. !5517 (dixpac) - Add commit stats in commit api. !5517 (dixpac)
- Add CI configuration button on project page - Add CI configuration button on project page
- Fix merge request new view not changing code view rendering style - Fix merge request new view not changing code view rendering style
- edit_blob_link will use blob passed onto the options parameter
- Make error pages responsive (Takuya Noguchi) - Make error pages responsive (Takuya Noguchi)
- The performance of the project dropdown used for moving issues has been improved - The performance of the project dropdown used for moving issues has been improved
- Fix skip_repo parameter being ignored when destroying a namespace - Fix skip_repo parameter being ignored when destroying a namespace
......
...@@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres ...@@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres
# Authentication libraries # Authentication libraries
gem 'devise', '~> 4.0' gem 'devise', '~> 4.0'
gem 'doorkeeper', '~> 4.0' gem 'doorkeeper', '~> 4.2.0'
gem 'omniauth', '~> 1.3.1' gem 'omniauth', '~> 1.3.1'
gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-auth0', '~> 1.4.1'
gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-azure-oauth2', '~> 0.0.6'
...@@ -82,7 +82,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' ...@@ -82,7 +82,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
gem 'kaminari', '~> 0.17.0' gem 'kaminari', '~> 0.17.0'
# HAML # HAML
gem 'hamlit', '~> 2.5' gem 'hamlit', '~> 2.6.1'
# Files attachments # Files attachments
gem 'carrierwave', '~> 0.10.0' gem 'carrierwave', '~> 0.10.0'
...@@ -211,7 +211,7 @@ gem 'licensee', '~> 8.0.0' ...@@ -211,7 +211,7 @@ gem 'licensee', '~> 8.0.0'
gem 'rack-attack', '~> 4.3.1' gem 'rack-attack', '~> 4.3.1'
# Ace editor # Ace editor
gem 'ace-rails-ap', '~> 4.0.2' gem 'ace-rails-ap', '~> 4.1.0'
# Keyboard shortcuts # Keyboard shortcuts
gem 'mousetrap-rails', '~> 1.4.6' gem 'mousetrap-rails', '~> 1.4.6'
...@@ -219,7 +219,8 @@ gem 'mousetrap-rails', '~> 1.4.6' ...@@ -219,7 +219,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding # Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.3' gem 'charlock_holmes', '~> 0.7.3'
# Parse duration # Parse time & duration
gem 'chronic', '~> 0.10.2'
gem 'chronic_duration', '~> 0.10.6' gem 'chronic_duration', '~> 0.10.6'
gem 'sass-rails', '~> 5.0.0' gem 'sass-rails', '~> 5.0.0'
......
...@@ -2,7 +2,7 @@ GEM ...@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
RedCloth (4.3.2) RedCloth (4.3.2)
ace-rails-ap (4.0.2) ace-rails-ap (4.1.0)
actionmailer (4.2.7.1) actionmailer (4.2.7.1)
actionpack (= 4.2.7.1) actionpack (= 4.2.7.1)
actionview (= 4.2.7.1) actionview (= 4.2.7.1)
...@@ -128,6 +128,7 @@ GEM ...@@ -128,6 +128,7 @@ GEM
mime-types (>= 1.16) mime-types (>= 1.16)
cause (0.1) cause (0.1)
charlock_holmes (0.7.3) charlock_holmes (0.7.3)
chronic (0.10.2)
chronic_duration (0.10.6) chronic_duration (0.10.6)
numerizer (~> 0.1.1) numerizer (~> 0.1.1)
chunky_png (1.3.5) chunky_png (1.3.5)
...@@ -175,7 +176,7 @@ GEM ...@@ -175,7 +176,7 @@ GEM
diff-lcs (1.2.5) diff-lcs (1.2.5)
diffy (3.0.7) diffy (3.0.7)
docile (1.1.5) docile (1.1.5)
doorkeeper (4.0.0) doorkeeper (4.2.0)
railties (>= 4.2) railties (>= 4.2)
dropzonejs-rails (0.7.2) dropzonejs-rails (0.7.2)
rails (> 3.1) rails (> 3.1)
...@@ -345,7 +346,7 @@ GEM ...@@ -345,7 +346,7 @@ GEM
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
gssapi (1.2.0) gssapi (1.2.0)
ffi (>= 1.0.1) ffi (>= 1.0.1)
hamlit (2.5.0) hamlit (2.6.1)
temple (~> 0.7.6) temple (~> 0.7.6)
thor thor
tilt tilt
...@@ -825,7 +826,7 @@ PLATFORMS ...@@ -825,7 +826,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
RedCloth (~> 4.3.2) RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.0.2) ace-rails-ap (~> 4.1.0)
activerecord-session_store (~> 1.0.0) activerecord-session_store (~> 1.0.0)
acts-as-taggable-on (~> 3.4) acts-as-taggable-on (~> 3.4)
addressable (~> 2.3.8) addressable (~> 2.3.8)
...@@ -851,6 +852,7 @@ DEPENDENCIES ...@@ -851,6 +852,7 @@ DEPENDENCIES
capybara-screenshot (~> 1.0.0) capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0) carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3) charlock_holmes (~> 0.7.3)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6) chronic_duration (~> 0.10.6)
coffee-rails (~> 4.1.0) coffee-rails (~> 4.1.0)
connection_pool (~> 2.0) connection_pool (~> 2.0)
...@@ -861,7 +863,7 @@ DEPENDENCIES ...@@ -861,7 +863,7 @@ DEPENDENCIES
devise (~> 4.0) devise (~> 4.0)
devise-two-factor (~> 3.0.0) devise-two-factor (~> 3.0.0)
diffy (~> 3.0.3) diffy (~> 3.0.3)
doorkeeper (~> 4.0) doorkeeper (~> 4.2.0)
dropzonejs-rails (~> 0.7.1) dropzonejs-rails (~> 0.7.1)
elasticsearch-model elasticsearch-model
elasticsearch-rails elasticsearch-rails
...@@ -897,7 +899,7 @@ DEPENDENCIES ...@@ -897,7 +899,7 @@ DEPENDENCIES
grape (~> 0.15.0) grape (~> 0.15.0)
grape-entity (~> 0.4.2) grape-entity (~> 0.4.2)
gssapi gssapi
hamlit (~> 2.5) hamlit (~> 2.6.1)
health_check (~> 2.1.0) health_check (~> 2.1.0)
hipchat (~> 1.5.0) hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0) html-pipeline (~> 1.11.0)
......
...@@ -10,10 +10,11 @@ ...@@ -10,10 +10,11 @@
gitignorePath: "/api/:version/gitignores/:key", gitignorePath: "/api/:version/gitignores/:key",
ldapGroupsPath: "/api/:version/ldap/:provider/groups.json", ldapGroupsPath: "/api/:version/ldap/:provider/groups.json",
gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key",
issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
group: function(group_id, callback) { group: function(group_id, callback) {
var url; var url = Api.buildUrl(Api.groupPath)
url = Api.buildUrl(Api.groupPath); .replace(':id', group_id);
url = url.replace(':id', group_id);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -25,8 +26,7 @@ ...@@ -25,8 +26,7 @@
}); });
}, },
groups: function(query, skip_ldap, callback) { groups: function(query, skip_ldap, callback) {
var url; var url = Api.buildUrl(Api.groupsPath);
url = Api.buildUrl(Api.groupsPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -40,8 +40,7 @@ ...@@ -40,8 +40,7 @@
}); });
}, },
namespaces: function(query, callback) { namespaces: function(query, callback) {
var url; var url = Api.buildUrl(Api.namespacesPath);
url = Api.buildUrl(Api.namespacesPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -55,8 +54,7 @@ ...@@ -55,8 +54,7 @@
}); });
}, },
projects: function(query, order, callback) { projects: function(query, order, callback) {
var url; var url = Api.buildUrl(Api.projectsPath);
url = Api.buildUrl(Api.projectsPath);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -71,9 +69,8 @@ ...@@ -71,9 +69,8 @@
}); });
}, },
newLabel: function(project_id, data, callback) { newLabel: function(project_id, data, callback) {
var url; var url = Api.buildUrl(Api.labelsPath)
url = Api.buildUrl(Api.labelsPath); .replace(':id', project_id);
url = url.replace(':id', project_id);
data.private_token = gon.api_token; data.private_token = gon.api_token;
return $.ajax({ return $.ajax({
url: url, url: url,
...@@ -87,9 +84,8 @@ ...@@ -87,9 +84,8 @@
}); });
}, },
groupProjects: function(group_id, query, callback) { groupProjects: function(group_id, query, callback) {
var url; var url = Api.buildUrl(Api.groupProjectsPath)
url = Api.buildUrl(Api.groupProjectsPath); .replace(':id', group_id);
url = url.replace(':id', group_id);
return $.ajax({ return $.ajax({
url: url, url: url,
data: { data: {
...@@ -103,8 +99,8 @@ ...@@ -103,8 +99,8 @@
}); });
}, },
licenseText: function(key, data, callback) { licenseText: function(key, data, callback) {
var url; var url = Api.buildUrl(Api.licensePath)
url = Api.buildUrl(Api.licensePath).replace(':key', key); .replace(':key', key);
return $.ajax({ return $.ajax({
url: url, url: url,
data: data data: data
...@@ -113,19 +109,32 @@ ...@@ -113,19 +109,32 @@
}); });
}, },
gitignoreText: function(key, callback) { gitignoreText: function(key, callback) {
var url; var url = Api.buildUrl(Api.gitignorePath)
url = Api.buildUrl(Api.gitignorePath).replace(':key', key); .replace(':key', key);
return $.get(url, function(gitignore) { return $.get(url, function(gitignore) {
return callback(gitignore); return callback(gitignore);
}); });
}, },
gitlabCiYml: function(key, callback) { gitlabCiYml: function(key, callback) {
var url; var url = Api.buildUrl(Api.gitlabCiYmlPath)
url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); .replace(':key', key);
return $.get(url, function(file) { return $.get(url, function(file) {
return callback(file); return callback(file);
}); });
}, },
issueTemplate: function(namespacePath, projectPath, key, type, callback) {
var url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key)
.replace(':type', type)
.replace(':project_path', projectPath)
.replace(':namespace_path', namespacePath);
$.ajax({
url: url,
dataType: 'json'
}).done(function(file) {
callback(null, file);
}).error(callback);
},
buildUrl: function(url) { buildUrl: function(url) {
if (gon.relative_url_root != null) { if (gon.relative_url_root != null) {
url = gon.relative_url_root + url; url = gon.relative_url_root + url;
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
/*= require bootstrap/tooltip */ /*= require bootstrap/tooltip */
/*= require bootstrap/popover */ /*= require bootstrap/popover */
/*= require select2 */ /*= require select2 */
/*= require ace/ace */ /*= require ace-rails-ap */
/*= require ace/ext-searchbox */ /*= require ace/ext-searchbox */
/*= require underscore */ /*= require underscore */
/*= require dropzone */ /*= require dropzone */
...@@ -42,6 +42,7 @@ ...@@ -42,6 +42,7 @@
/*= require date.format */ /*= require date.format */
/*= require_directory ./behaviors */ /*= require_directory ./behaviors */
/*= require_directory ./blob */ /*= require_directory ./blob */
/*= require_directory ./templates */
/*= require_directory ./commit */ /*= require_directory ./commit */
/*= require_directory ./extensions */ /*= require_directory ./extensions */
/*= require_directory ./lib/utils */ /*= require_directory ./lib/utils */
...@@ -227,10 +228,13 @@ ...@@ -227,10 +228,13 @@
}); });
$body.on("click", ".js-toggle-diff-comments", function(e) { $body.on("click", ".js-toggle-diff-comments", function(e) {
var $this = $(this); var $this = $(this);
var showComments = $this.hasClass('active');
$this.toggleClass('active'); $this.toggleClass('active');
$this.closest(".diff-file").find(".notes_holder").toggle(showComments); var notesHolders = $this.closest('.diff-file').find('.notes_holder');
if ($this.hasClass('active')) {
notesHolders.show();
} else {
notesHolders.hide();
}
return e.preventDefault(); return e.preventDefault();
}); });
$document.off("click", '.js-confirm-danger'); $document.off("click", '.js-confirm-danger');
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
} }
this.onClick = bind(this.onClick, this); this.onClick = bind(this.onClick, this);
this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name');
this.dropdownIcon = $('.fa-chevron-down', this.dropdown);
this.buildDropdown(); this.buildDropdown();
this.bindEvents(); this.bindEvents();
this.onFilenameUpdate(); this.onFilenameUpdate();
...@@ -60,11 +61,26 @@ ...@@ -60,11 +61,26 @@
return this.requestFile(item); return this.requestFile(item);
}; };
TemplateSelector.prototype.requestFile = function(item) {}; TemplateSelector.prototype.requestFile = function(item) {
// This `requestFile` method is an abstract method that should
// be added by all subclasses.
};
TemplateSelector.prototype.requestFileSuccess = function(file) { TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) {
this.editor.setValue(file.content, 1); this.editor.setValue(file.content, 1);
return this.editor.focus(); if (!skipFocus) this.editor.focus();
};
TemplateSelector.prototype.startLoadingSpinner = function() {
this.dropdownIcon
.addClass('fa-spinner fa-spin')
.removeClass('fa-chevron-down');
};
TemplateSelector.prototype.stopLoadingSpinner = function() {
this.dropdownIcon
.addClass('fa-chevron-down')
.removeClass('fa-spinner fa-spin');
}; };
return TemplateSelector; return TemplateSelector;
......
((w) => {
w.CommentAndResolveBtn = Vue.extend({
props: {
discussionId: String,
textareaIsEmpty: Boolean
},
computed: {
discussion: function () {
return CommentsStore.state[this.discussionId];
},
showButton: function () {
if (this.discussion) {
return this.discussion.isResolvable();
} else {
return false;
}
},
isDiscussionResolved: function () {
return this.discussion.isResolved();
},
buttonText: function () {
if (this.isDiscussionResolved) {
if (this.textareaIsEmpty) {
return "Unresolve discussion";
} else {
return "Comment & unresolve discussion";
}
} else {
if (this.textareaIsEmpty) {
return "Resolve discussion";
} else {
return "Comment & resolve discussion";
}
}
}
},
ready: function () {
const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
this.textareaIsEmpty = $textarea.val() === '';
$textarea.on('input.comment-and-resolve-btn', () => {
this.textareaIsEmpty = $textarea.val() === '';
});
},
destroyed: function () {
$(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
}
});
})(window);
(() => {
JumpToDiscussion = Vue.extend({
mixins: [DiscussionMixins],
props: {
discussionId: String
},
data: function () {
return {
discussions: CommentsStore.state,
};
},
computed: {
discussion: function () {
return this.discussions[this.discussionId];
},
allResolved: function () {
return this.unresolvedDiscussionCount === 0;
},
showButton: function () {
if (this.discussionId) {
if (this.unresolvedDiscussionCount > 1) {
return true;
} else {
return this.discussionId !== this.lastResolvedId;
}
} else {
return this.unresolvedDiscussionCount >= 1;
}
},
lastResolvedId: function () {
let lastId;
for (const discussionId in this.discussions) {
const discussion = this.discussions[discussionId];
if (!discussion.isResolved()) {
lastId = discussion.id;
}
}
return lastId;
}
},
methods: {
jumpToNextUnresolvedDiscussion: function () {
let discussionsSelector,
discussionIdsInScope,
firstUnresolvedDiscussionId,
nextUnresolvedDiscussionId,
activeTab = window.mrTabs.currentAction,
hasDiscussionsToJumpTo = true,
jumpToFirstDiscussion = !this.discussionId;
const discussionIdsForElements = function(elements) {
return elements.map(function() {
return $(this).attr('data-discussion-id');
}).toArray();
};
const discussions = this.discussions;
if (activeTab === 'diffs') {
discussionsSelector = '.diffs .notes[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
let unresolvedDiscussionCount = 0;
for (let i = 0; i < discussionIdsInScope.length; i++) {
const discussionId = discussionIdsInScope[i];
const discussion = discussions[discussionId];
if (discussion && !discussion.isResolved()) {
unresolvedDiscussionCount++;
}
}
if (this.discussionId && !this.discussion.isResolved()) {
// If this is the last unresolved discussion on the diffs tab,
// there are no discussions to jump to.
if (unresolvedDiscussionCount === 1) {
hasDiscussionsToJumpTo = false;
}
} else {
// If there are no unresolved discussions on the diffs tab at all,
// there are no discussions to jump to.
if (unresolvedDiscussionCount === 0) {
hasDiscussionsToJumpTo = false;
}
}
} else if (activeTab !== 'notes') {
// If we are on the commits or builds tabs,
// there are no discussions to jump to.
hasDiscussionsToJumpTo = false;
}
if (!hasDiscussionsToJumpTo) {
// If there are no discussions to jump to on the current page,
// switch to the notes tab and jump to the first disucssion there.
window.mrTabs.activateTab('notes');
activeTab = 'notes';
jumpToFirstDiscussion = true;
}
if (activeTab === 'notes') {
discussionsSelector = '.discussion[data-discussion-id]';
discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
}
let currentDiscussionFound = false;
for (let i = 0; i < discussionIdsInScope.length; i++) {
const discussionId = discussionIdsInScope[i];
const discussion = discussions[discussionId];
if (!discussion) {
// Discussions for comments on commits in this MR don't have a resolved status.
continue;
}
if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
firstUnresolvedDiscussionId = discussionId;
if (jumpToFirstDiscussion) {
break;
}
}
if (!jumpToFirstDiscussion) {
if (currentDiscussionFound) {
if (!discussion.isResolved()) {
nextUnresolvedDiscussionId = discussionId;
break;
}
else {
continue;
}
}
if (discussionId === this.discussionId) {
currentDiscussionFound = true;
}
}
}
nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
if (!nextUnresolvedDiscussionId) {
return;
}
let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
if (activeTab === 'notes') {
$target = $target.closest('.note-discussion');
// If the next discussion is closed, toggle it open.
if ($target.find('.js-toggle-content').is(':hidden')) {
$target.find('.js-toggle-button i').trigger('click')
}
} else if (activeTab === 'diffs') {
// Resolved discussions are hidden in the diffs tab by default.
// If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
// When jumping between unresolved discussions on the diffs tab, we show them.
$target.closest(".content").show();
$target = $target.closest("tr.notes_holder");
$target.show();
// If we are on the diffs tab, we don't scroll to the discussion itself, but to
// 4 diff lines above it: the line the discussion was in response to + 3 context
let prevEl;
for (let i = 0; i < 4; i++) {
prevEl = $target.prev();
// If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
if (!prevEl.hasClass("line_holder")) {
break;
}
$target = prevEl;
}
}
$.scrollTo($target, {
offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight())
});
}
}
});
Vue.component('jump-to-discussion', JumpToDiscussion);
})();
((w) => {
w.ResolveBtn = Vue.extend({
mixins: [
ButtonMixins
],
props: {
noteId: Number,
discussionId: String,
resolved: Boolean,
namespacePath: String,
projectPath: String,
canResolve: Boolean,
resolvedBy: String
},
data: function () {
return {
discussions: CommentsStore.state,
loading: false
};
},
watch: {
'discussions': {
handler: 'updateTooltip',
deep: true
}
},
computed: {
discussion: function () {
return this.discussions[this.discussionId];
},
note: function () {
if (this.discussion) {
return this.discussion.getNote(this.noteId);
} else {
return undefined;
}
},
buttonText: function () {
if (this.isResolved) {
return `Resolved by ${this.resolvedByName}`;
} else if (this.canResolve) {
return 'Mark as resolved';
} else {
return 'Unable to resolve';
}
},
isResolved: function () {
if (this.note) {
return this.note.resolved;
} else {
return false;
}
},
resolvedByName: function () {
return this.note.resolved_by;
},
},
methods: {
updateTooltip: function () {
$(this.$els.button)
.tooltip('hide')
.tooltip('fixTitle');
},
resolve: function () {
if (!this.canResolve) return;
let promise;
this.loading = true;
if (this.isResolved) {
promise = ResolveService
.unresolve(this.namespace, this.noteId);
} else {
promise = ResolveService
.resolve(this.namespace, this.noteId);
}
promise.then((response) => {
this.loading = false;
if (response.status === 200) {
const data = response.json();
const resolved_by = data ? data.resolved_by : null;
CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
this.discussion.updateHeadline(data);
} else {
new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert');
}
this.$nextTick(this.updateTooltip);
});
}
},
compiled: function () {
$(this.$els.button).tooltip({
container: 'body'
});
},
beforeDestroy: function () {
CommentsStore.delete(this.discussionId, this.noteId);
},
created: function () {
CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy);
}
});
})(window);
((w) => {
w.ResolveCount = Vue.extend({
mixins: [DiscussionMixins],
props: {
loggedOut: Boolean
},
data: function () {
return {
discussions: CommentsStore.state
};
},
computed: {
allResolved: function () {
return this.resolvedDiscussionCount === this.discussionCount;
}
}
});
})(window);
((w) => {
w.ResolveDiscussionBtn = Vue.extend({
mixins: [
ButtonMixins
],
props: {
discussionId: String,
mergeRequestId: Number,
namespacePath: String,
projectPath: String,
canResolve: Boolean,
},
data: function() {
return {
discussions: CommentsStore.state
};
},
computed: {
discussion: function () {
return this.discussions[this.discussionId];
},
showButton: function () {
if (this.discussion) {
return this.discussion.isResolvable();
} else {
return false;
}
},
isDiscussionResolved: function () {
if (this.discussion) {
return this.discussion.isResolved();
} else {
return false;
}
},
buttonText: function () {
if (this.isDiscussionResolved) {
return "Unresolve discussion";
} else {
return "Resolve discussion";
}
},
loading: function () {
if (this.discussion) {
return this.discussion.loading;
} else {
return false;
}
}
},
methods: {
resolve: function () {
ResolveService.toggleResolveForDiscussion(this.namespace, this.mergeRequestId, this.discussionId);
}
},
created: function () {
CommentsStore.createDiscussion(this.discussionId, this.canResolve);
}
});
})(window);
//= require vue
//= require vue-resource
//= require_directory ./models
//= require_directory ./stores
//= require_directory ./services
//= require_directory ./mixins
//= require_directory ./components
$(() => {
window.DiffNotesApp = new Vue({
el: '#diff-notes-app',
components: {
'resolve-btn': ResolveBtn,
'resolve-discussion-btn': ResolveDiscussionBtn,
'comment-and-resolve-btn': CommentAndResolveBtn
},
methods: {
compileComponents: function () {
const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion');
if ($components.length) {
$components.each(function () {
DiffNotesApp.$compile($(this).get(0));
});
}
}
}
});
new Vue({
el: '#resolve-count-app',
components: {
'resolve-count': ResolveCount
}
});
});
((w) => {
w.DiscussionMixins = {
computed: {
discussionCount: function () {
return Object.keys(this.discussions).length;
},
resolvedDiscussionCount: function () {
let resolvedCount = 0;
for (const discussionId in this.discussions) {
const discussion = this.discussions[discussionId];
if (discussion.isResolved()) {
resolvedCount++;
}
}
return resolvedCount;
},
unresolvedDiscussionCount: function () {
let unresolvedCount = 0;
for (const discussionId in this.discussions) {
const discussion = this.discussions[discussionId];
if (!discussion.isResolved()) {
unresolvedCount++;
}
}
return unresolvedCount;
}
}
};
})(window);
((w) => {
w.ButtonMixins = {
computed: {
namespace: function () {
return `${this.namespacePath}/${this.projectPath}`;
}
}
};
})(window);
class DiscussionModel {
constructor (discussionId) {
this.id = discussionId;
this.notes = {};
this.loading = false;
this.canResolve = false;
}
createNote (noteId, canResolve, resolved, resolved_by) {
Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by));
}
deleteNote (noteId) {
Vue.delete(this.notes, noteId);
}
getNote (noteId) {
return this.notes[noteId];
}
notesCount() {
return Object.keys(this.notes).length;
}
isResolved () {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (!note.resolved) {
return false;
}
}
return true;
}
resolveAllNotes (resolved_by) {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (!note.resolved) {
note.resolved = true;
note.resolved_by = resolved_by;
}
}
}
unResolveAllNotes () {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (note.resolved) {
note.resolved = false;
note.resolved_by = null;
}
}
}
updateHeadline (data) {
const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`);
if (data.discussion_headline_html) {
if ($discussionHeadline.length) {
$discussionHeadline.replaceWith(data.discussion_headline_html);
} else {
$(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html);
}
} else {
$discussionHeadline.remove();
}
}
isResolvable () {
if (!this.canResolve) {
return false;
}
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (note.canResolve) {
return true;
}
}
return false;
}
}
class NoteModel {
constructor (discussionId, noteId, canResolve, resolved, resolved_by) {
this.discussionId = discussionId;
this.id = noteId;
this.canResolve = canResolve;
this.resolved = resolved;
this.resolved_by = resolved_by;
}
}
((w) => {
class ResolveServiceClass {
constructor() {
this.noteResource = Vue.resource('notes{/noteId}/resolve');
this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve');
}
setCSRF() {
Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken();
}
prepareRequest(namespace) {
this.setCSRF();
Vue.http.options.root = `/${namespace}`;
}
resolve(namespace, noteId) {
this.prepareRequest(namespace);
return this.noteResource.save({ noteId }, {});
}
unresolve(namespace, noteId) {
this.prepareRequest(namespace);
return this.noteResource.delete({ noteId }, {});
}
toggleResolveForDiscussion(namespace, mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId],
isResolved = discussion.isResolved();
let promise;
if (isResolved) {
promise = this.unResolveAll(namespace, mergeRequestId, discussionId);
} else {
promise = this.resolveAll(namespace, mergeRequestId, discussionId);
}
promise.then((response) => {
discussion.loading = false;
if (response.status === 200) {
const data = response.json();
const resolved_by = data ? data.resolved_by : null;
if (isResolved) {
discussion.unResolveAllNotes();
} else {
discussion.resolveAllNotes(resolved_by);
}
discussion.updateHeadline(data);
} else {
new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert');
}
})
}
resolveAll(namespace, mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId];
this.prepareRequest(namespace);
discussion.loading = true;
return this.discussionResource.save({
mergeRequestId,
discussionId
}, {});
}
unResolveAll(namespace, mergeRequestId, discussionId) {
const discussion = CommentsStore.state[discussionId];
this.prepareRequest(namespace);
discussion.loading = true;
return this.discussionResource.delete({
mergeRequestId,
discussionId
}, {});
}
}
w.ResolveService = new ResolveServiceClass();
})(window);
((w) => {
w.CommentsStore = {
state: {},
get: function (discussionId, noteId) {
return this.state[discussionId].getNote(noteId);
},
createDiscussion: function (discussionId, canResolve) {
let discussion = this.state[discussionId];
if (!this.state[discussionId]) {
discussion = new DiscussionModel(discussionId);
Vue.set(this.state, discussionId, discussion);
}
if (canResolve !== undefined) {
discussion.canResolve = canResolve;
}
return discussion;
},
create: function (discussionId, noteId, canResolve, resolved, resolved_by) {
const discussion = this.createDiscussion(discussionId);
discussion.createNote(noteId, canResolve, resolved, resolved_by);
},
update: function (discussionId, noteId, resolved, resolved_by) {
const discussion = this.state[discussionId];
const note = discussion.getNote(noteId);
note.resolved = resolved;
note.resolved_by = resolved_by;
},
delete: function (discussionId, noteId) {
const discussion = this.state[discussionId];
discussion.deleteNote(noteId);
if (discussion.notesCount() === 0) {
Vue.delete(this.state, discussionId);
}
},
unresolvedDiscussionIds: function () {
let ids = [];
for (const discussionId in this.state) {
const discussion = this.state[discussionId];
if (!discussion.isResolved()) {
ids.push(discussion.id);
}
}
return ids;
}
};
})(window);
...@@ -55,6 +55,7 @@ ...@@ -55,6 +55,7 @@
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new GLForm($('.issue-form')); new GLForm($('.issue-form'));
new IssuableForm($('.issue-form')); new IssuableForm($('.issue-form'));
new IssuableTemplateSelectors();
break; break;
case 'projects:merge_requests:new': case 'projects:merge_requests:new':
case 'projects:merge_requests:edit': case 'projects:merge_requests:edit':
...@@ -62,6 +63,7 @@ ...@@ -62,6 +63,7 @@
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new GLForm($('.merge-request-form')); new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form')); new IssuableForm($('.merge-request-form'));
new IssuableTemplateSelectors();
break; break;
case 'projects:tags:new': case 'projects:tags:new':
new ZenMode(); new ZenMode();
......
...@@ -223,7 +223,7 @@ ...@@ -223,7 +223,7 @@
} }
} }
}); });
return this.input.atwho({ this.input.atwho({
at: '~', at: '~',
alias: 'labels', alias: 'labels',
searchKey: 'search', searchKey: 'search',
...@@ -249,6 +249,68 @@ ...@@ -249,6 +249,68 @@
} }
} }
}); });
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
this.input.filter('[data-supports-slash-commands="true"]').atwho({
at: '/',
alias: 'commands',
searchKey: 'search',
displayTpl: function(value) {
var tpl = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
tpl += ' <small><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
}
tpl += '</li>';
return _.template(tpl)(value);
},
insertTpl: function(value) {
var tpl = "/${name} ";
var reference_prefix = null;
if (value.params.length > 0) {
reference_prefix = value.params[0][0];
if (/^[@%~]/.test(reference_prefix)) {
tpl += '<%- reference_prefix %>';
}
}
return _.template(tpl)({ reference_prefix: reference_prefix });
},
suffix: '',
callbacks: {
sorter: this.DefaultOptions.sorter,
filter: this.DefaultOptions.filter,
beforeInsert: this.DefaultOptions.beforeInsert,
beforeSave: function(commands) {
return $.map(commands, function(c) {
var search = c.name;
if (c.aliases.length > 0) {
search = search + " " + c.aliases.join(" ");
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
search: search
};
});
},
matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi
var match = regexp.exec(subtext);
if (match) {
return match[1];
} else {
return null;
}
}
}
});
return;
}, },
destroyAtWho: function() { destroyAtWho: function() {
return this.input.atwho('destroy'); return this.input.atwho('destroy');
...@@ -265,6 +327,7 @@ ...@@ -265,6 +327,7 @@
this.input.atwho('load', 'mergerequests', data.mergerequests); this.input.atwho('load', 'mergerequests', data.mergerequests);
this.input.atwho('load', ':', data.emojis); this.input.atwho('load', ':', data.emojis);
this.input.atwho('load', '~', data.labels); this.input.atwho('load', '~', data.labels);
this.input.atwho('load', '/', data.commands);
return $(':focus').trigger('keyup'); return $(':focus').trigger('keyup');
} }
}; };
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
MergeRequest.prototype.initTabs = function() { MergeRequest.prototype.initTabs = function() {
if (this.opts.action !== 'new') { if (this.opts.action !== 'new') {
return new MergeRequestTabs(this.opts); window.mrTabs = new MergeRequestTabs(this.opts);
} else { } else {
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show'); return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
} }
......
...@@ -89,6 +89,7 @@ ...@@ -89,6 +89,7 @@
if (action === 'show') { if (action === 'show') {
action = 'notes'; action = 'notes';
} }
this.currentAction = action;
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, ''); new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
if (action !== 'notes') { if (action !== 'notes') {
new_state += "/" + action; new_state += "/" + action;
...@@ -127,6 +128,11 @@ ...@@ -127,6 +128,11 @@
success: (function(_this) { success: (function(_this) {
return function(data) { return function(data) {
$('#diffs').html(data.html); $('#diffs').html(data.html);
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
$('#diffs .js-syntax-highlight').syntaxHighlight(); $('#diffs .js-syntax-highlight').syntaxHighlight();
$('#diffs .diff-file').singleFileDiff(); $('#diffs .diff-file').singleFileDiff();
......
...@@ -68,6 +68,7 @@ ...@@ -68,6 +68,7 @@
$(document).on("click", ".note-edit-cancel", this.cancelEdit); $(document).on("click", ".note-edit-cancel", this.cancelEdit);
$(document).on("click", ".js-comment-button", this.updateCloseButton); $(document).on("click", ".js-comment-button", this.updateCloseButton);
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
$(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
$(document).on("click", ".js-note-delete", this.removeNote); $(document).on("click", ".js-note-delete", this.removeNote);
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment); $(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
...@@ -100,6 +101,7 @@ ...@@ -100,6 +101,7 @@
$(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-target-close");
$(document).off("click", ".js-note-discard"); $(document).off("click", ".js-note-discard");
$(document).off("keydown", ".js-note-text"); $(document).off("keydown", ".js-note-text");
$(document).off('click', '.js-comment-resolve-button');
$('.note .js-task-list-container').taskList('disable'); $('.note .js-task-list-container').taskList('disable');
return $(document).off('tasklist:changed', '.note .js-task-list-container'); return $(document).off('tasklist:changed', '.note .js-task-list-container');
}; };
...@@ -231,7 +233,13 @@ ...@@ -231,7 +233,13 @@
var $notesList, votesBlock; var $notesList, votesBlock;
if (!note.valid) { if (!note.valid) {
if (note.award) { if (note.award) {
new Flash('You have already awarded this emoji!', 'alert'); new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
}
else {
if (note.errors.commands_only) {
new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
this.refresh();
}
} }
return; return;
} }
...@@ -245,6 +253,7 @@ ...@@ -245,6 +253,7 @@
$notesList.append(note.html).syntaxHighlight(); $notesList.append(note.html).syntaxHighlight();
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.initTaskList(); this.initTaskList();
this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
} }
}; };
...@@ -297,6 +306,11 @@ ...@@ -297,6 +306,11 @@
} else { } else {
discussionContainer.append(note_html); discussionContainer.append(note_html);
} }
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
gl.utils.localTimeAgo($('.js-timeago', note_html), false); gl.utils.localTimeAgo($('.js-timeago', note_html), false);
return this.updateNotesCount(1); return this.updateNotesCount(1);
}; };
...@@ -343,6 +357,7 @@ ...@@ -343,6 +357,7 @@
form.find("#note_line_code").remove(); form.find("#note_line_code").remove();
form.find("#note_position").remove(); form.find("#note_position").remove();
form.find("#note_type").remove(); form.find("#note_type").remove();
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
return this.parentTimeline = form.parents('.timeline'); return this.parentTimeline = form.parents('.timeline');
}; };
...@@ -386,8 +401,22 @@ ...@@ -386,8 +401,22 @@
*/ */
Notes.prototype.addDiscussionNote = function(xhr, note, status) { Notes.prototype.addDiscussionNote = function(xhr, note, status) {
var $form = $(xhr.target);
if ($form.attr('data-resolve-all') != null) {
var namespacePath = $form.attr('data-namespace-path'),
projectPath = $form.attr('data-project-path')
discussionId = $form.attr('data-discussion-id'),
mergeRequestId = $form.attr('data-noteable-iid'),
namespace = namespacePath + '/' + projectPath;
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(namespace, mergeRequestId, discussionId);
}
}
this.renderDiscussionNote(note); this.renderDiscussionNote(note);
return this.removeDiscussionNoteForm($(xhr.target)); this.removeDiscussionNoteForm($form);
}; };
...@@ -404,7 +433,12 @@ ...@@ -404,7 +433,12 @@
$html.syntaxHighlight(); $html.syntaxHighlight();
$html.find('.js-task-list-container').taskList('enable'); $html.find('.js-task-list-container').taskList('enable');
$note_li = $('.note-row-' + note.id); $note_li = $('.note-row-' + note.id);
return $note_li.replaceWith($html);
$note_li.replaceWith($html);
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
}; };
...@@ -485,6 +519,15 @@ ...@@ -485,6 +519,15 @@
var note, notes; var note, notes;
note = $(el); note = $(el);
notes = note.closest(".notes"); notes = note.closest(".notes");
if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) {
ref = DiffNotesApp.$refs[noteId];
if (ref) {
ref.$destroy(true);
}
}
if (notes.find(".note").length === 1) { if (notes.find(".note").length === 1) {
notes.closest(".timeline-entry").remove(); notes.closest(".timeline-entry").remove();
notes.closest("tr").remove(); notes.closest("tr").remove();
...@@ -523,8 +566,10 @@ ...@@ -523,8 +566,10 @@
var form, replyLink; var form, replyLink;
form = this.formClone.clone(); form = this.formClone.clone();
replyLink = $(e.target).closest(".js-discussion-reply-button"); replyLink = $(e.target).closest(".js-discussion-reply-button");
replyLink.hide(); replyLink
replyLink.after(form); .closest('.discussion-reply-holder')
.hide()
.after(form);
return this.setupDiscussionNoteForm(replyLink, form); return this.setupDiscussionNoteForm(replyLink, form);
}; };
...@@ -549,9 +594,23 @@ ...@@ -549,9 +594,23 @@
form.find("#note_noteable_type").val(dataHolder.data("noteableType")); form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
form.find('.js-note-target-close').remove();
this.setupNoteForm(form); this.setupNoteForm(form);
if (typeof DiffNotesApp !== 'undefined') {
var $commentBtn = form.find('comment-and-resolve-btn');
$commentBtn
.attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
DiffNotesApp.$compile($commentBtn.get(0));
}
form.find(".js-note-text").focus(); form.find(".js-note-text").focus();
return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form"); form
.find('.js-comment-resolve-button')
.attr('data-discussion-id', dataHolder.data('discussionId'));
form
.removeClass('js-main-target-form')
.addClass("discussion-form js-discussion-note-form");
}; };
...@@ -570,16 +629,19 @@ ...@@ -570,16 +629,19 @@
nextRow = row.next(); nextRow = row.next();
hasNotes = nextRow.is(".notes_holder"); hasNotes = nextRow.is(".notes_holder");
addForm = false; addForm = false;
targetContent = ".notes_content"; notesContentSelector = ".notes_content";
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>"; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
if (this.isParallelView()) { if (this.isParallelView()) {
lineType = $link.data("lineType"); lineType = $link.data("lineType");
targetContent += "." + lineType; notesContentSelector += "." + lineType;
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>"; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
} }
notesContentSelector += " .content";
if (hasNotes) { if (hasNotes) {
notesContent = nextRow.find(targetContent); nextRow.show();
notesContent = nextRow.find(notesContentSelector);
if (notesContent.length) { if (notesContent.length) {
notesContent.show();
replyButton = notesContent.find(".js-discussion-reply-button:visible"); replyButton = notesContent.find(".js-discussion-reply-button:visible");
if (replyButton.length) { if (replyButton.length) {
e.target = replyButton[0]; e.target = replyButton[0];
...@@ -593,11 +655,13 @@ ...@@ -593,11 +655,13 @@
} }
} else { } else {
row.after(rowCssToAdd); row.after(rowCssToAdd);
nextRow = row.next();
notesContent = nextRow.find(notesContentSelector);
addForm = true; addForm = true;
} }
if (addForm) { if (addForm) {
newForm = this.formClone.clone(); newForm = this.formClone.clone();
newForm.appendTo(row.next().find(targetContent)); newForm.appendTo(notesContent);
return this.setupDiscussionNoteForm($link, newForm); return this.setupDiscussionNoteForm($link, newForm);
} }
}; };
...@@ -616,7 +680,9 @@ ...@@ -616,7 +680,9 @@
glForm = form.data('gl-form'); glForm = form.data('gl-form');
glForm.destroy(); glForm.destroy();
form.find(".js-note-text").data("autosave").reset(); form.find(".js-note-text").data("autosave").reset();
form.prev(".js-discussion-reply-button").show(); form
.prev('.discussion-reply-holder')
.show();
if (row.is(".js-temp-notes-holder")) { if (row.is(".js-temp-notes-holder")) {
return row.remove(); return row.remove();
} else { } else {
...@@ -725,6 +791,18 @@ ...@@ -725,6 +791,18 @@
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
}; };
Notes.prototype.resolveDiscussion = function () {
var $this = $(this),
discussionId = $this.attr('data-discussion-id');
$this
.closest('form')
.attr('data-discussion-id', discussionId)
.attr('data-resolve-all', 'true')
.attr('data-namespace-path', $this.attr('data-namespace-path'))
.attr('data-project-path', $this.attr('data-project-path'));
};
return Notes; return Notes;
})(); })();
......
...@@ -35,10 +35,16 @@ ...@@ -35,10 +35,16 @@
this.isOpen = !this.isOpen; this.isOpen = !this.isOpen;
if (!this.isOpen && !this.hasError) { if (!this.isOpen && !this.hasError) {
this.content.hide(); this.content.hide();
return this.collapsedContent.show(); this.collapsedContent.show();
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
} else if (this.content) { } else if (this.content) {
this.collapsedContent.hide(); this.collapsedContent.hide();
return this.content.show(); this.content.show();
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
} else { } else {
return this.getContentHTML(); return this.getContentHTML();
} }
...@@ -57,7 +63,11 @@ ...@@ -57,7 +63,11 @@
_this.hasError = true; _this.hasError = true;
_this.content = $(ERROR_HTML); _this.content = $(ERROR_HTML);
} }
return _this.collapsedContent.after(_this.content); _this.collapsedContent.after(_this.content);
if (typeof DiffNotesApp !== 'undefined') {
DiffNotesApp.compileComponents();
}
}; };
})(this)); })(this));
}; };
......
/*= require ../blob/template_selector */
((global) => {
class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
this.projectPath = this.dropdown.data('project-path');
this.namespacePath = this.dropdown.data('namespace-path');
this.issuableType = this.wrapper.data('issuable-type');
this.titleInput = $(`#${this.issuableType}_title`);
let initialQuery = {
name: this.dropdown.data('selected')
};
if (initialQuery.name) this.requestFile(initialQuery);
$('.reset-template', this.dropdown.parent()).on('click', () => {
if (this.currentTemplate) this.setInputValueToTemplateContent();
});
}
requestFile(query) {
this.startLoadingSpinner();
Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
this.currentTemplate = currentTemplate;
if (err) return; // Error handled by global AJAX error handler
this.stopLoadingSpinner();
this.setInputValueToTemplateContent();
});
return;
}
setInputValueToTemplateContent() {
// `this.requestFileSuccess` sets the value of the description input field
// to the content of the template selected.
if (this.titleInput.val() === '') {
// If the title has not yet been set, focus the title input and
// skip focusing the description input by setting `true` as the 2nd
// argument to `requestFileSuccess`.
this.requestFileSuccess(this.currentTemplate, true);
this.titleInput.focus();
} else {
this.requestFileSuccess(this.currentTemplate);
}
return;
}
}
global.IssuableTemplateSelector = IssuableTemplateSelector;
})(window);
((global) => {
class IssuableTemplateSelectors {
constructor(opts = {}) {
this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector');
this.editor = opts.editor || this.initEditor();
this.$dropdowns.each((i, dropdown) => {
let $dropdown = $(dropdown);
new IssuableTemplateSelector({
pattern: /(\.md)/,
data: $dropdown.data('data'),
wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
dropdown: $dropdown,
editor: this.editor
});
});
}
initEditor() {
let editor = $('.markdown-area');
// Proxy ace-editor's .setValue to jQuery's .val
editor.setValue = editor.val;
editor.getValue = editor.val;
return editor;
}
}
global.IssuableTemplateSelectors = IssuableTemplateSelectors;
})(window);
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
} }
} }
// Hide element if Vue is still working on rendering it fully.
[v-cloak] { [v-cloak="true"] {
display: none; display: none !important;
} }
...@@ -56,9 +56,13 @@ ...@@ -56,9 +56,13 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 6px; right: 6px;
margin-top: -4px; margin-top: -6px;
color: $dropdown-toggle-icon-color; color: $dropdown-toggle-icon-color;
font-size: 10px; font-size: 10px;
&.fa-spinner {
font-size: 16px;
margin-top: -8px;
}
} }
&:hover, { &:hover, {
...@@ -406,6 +410,7 @@ ...@@ -406,6 +410,7 @@
font-size: 14px; font-size: 14px;
a { a {
cursor: pointer;
padding-left: 10px; padding-left: 10px;
} }
} }
......
...@@ -147,3 +147,8 @@ ...@@ -147,3 +147,8 @@
color: $gl-link-color; color: $gl-link-color;
} }
} }
.atwho-view small.description {
float: right;
padding: 3px 5px;
}
...@@ -45,7 +45,6 @@ ...@@ -45,7 +45,6 @@
.line_content { .line_content {
padding-left: 0.5em; padding-left: 0.5em;
padding-right: 0.5em; padding-right: 0.5em;
white-space: pre;
&.old { &.old {
background-color: $line-removed; background-color: $line-removed;
...@@ -71,6 +70,10 @@ ...@@ -71,6 +70,10 @@
} }
} }
pre {
margin: 0;
}
span.highlight_word { span.highlight_word {
background-color: #fafe3d !important; background-color: #fafe3d !important;
} }
......
...@@ -395,3 +395,12 @@ ...@@ -395,3 +395,12 @@
display: inline-block; display: inline-block;
line-height: 18px; line-height: 18px;
} }
.js-issuable-selector-wrap {
.js-issuable-selector {
width: 100%;
}
@media (max-width: $screen-sm-max) {
margin-bottom: $gl-padding;
}
}
...@@ -159,6 +159,32 @@ ...@@ -159,6 +159,32 @@
} }
} }
.discussion-with-resolve-btn {
display: table;
width: 100%;
border-collapse: separate;
table-layout: auto;
.btn-group {
display: table-cell;
float: none;
width: 1%;
&:first-child {
width: 100%;
padding-right: 5px;
}
&:last-child {
padding-left: 5px;
}
}
.btn {
width: 100%;
}
}
.discussion-notes-count { .discussion-notes-count {
font-size: 16px; font-size: 16px;
} }
......
...@@ -383,3 +383,80 @@ ul.notes { ...@@ -383,3 +383,80 @@ ul.notes {
color: $gl-link-color; color: $gl-link-color;
} }
} }
.line-resolve-all-container {
.btn-group {
margin-top: -1px;
margin-left: -4px;
}
.discussion-next-btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.line-resolve-all {
display: inline-block;
padding: 5px 10px;
background-color: $background-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
&.has-next-btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.line-resolve-btn {
vertical-align: middle;
margin-right: 5px;
}
}
.line-resolve-text {
vertical-align: middle;
}
.line-resolve-btn {
display: inline-block;
position: relative;
top: 2px;
padding: 0;
background-color: transparent;
border: none;
outline: 0;
&.is-disabled {
cursor: default;
}
&:not(.is-disabled):hover,
&:not(.is-disabled):focus,
&.is-active {
color: $gl-text-green;
svg path {
fill: $gl-text-green;
}
}
svg {
position: relative;
color: $notes-action-color;
path {
fill: $notes-action-color;
}
}
}
.discussion-next-btn {
svg {
margin: 0;
path {
fill: $gray-darkest;
}
}
}
...@@ -228,3 +228,9 @@ ...@@ -228,3 +228,9 @@
} }
} }
} }
table.u2f-registrations {
th:not(:last-child), td:not(:last-child) {
border-right: solid 1px transparent;
}
}
\ No newline at end of file
...@@ -66,6 +66,11 @@ module IssuableCollections ...@@ -66,6 +66,11 @@ module IssuableCollections
key = 'issuable_sort' key = 'issuable_sort'
cookies[key] = params[:sort] if params[:sort].present? cookies[key] = params[:sort] if params[:sort].present?
# id_desc and id_asc are old values for these two.
cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc'
cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc'
params[:sort] = cookies[key] params[:sort] = cookies[key]
end end
......
...@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# A U2F (universal 2nd factor) device's information is stored after successful # A U2F (universal 2nd factor) device's information is stored after successful
# registration, which is then used while 2FA authentication is taking place. # registration, which is then used while 2FA authentication is taking place.
def create_u2f def create_u2f
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges]) @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
if @u2f_registration.persisted? if @u2f_registration.persisted?
session.delete(:challenges) session.delete(:challenges)
redirect_to profile_account_path, notice: "Your U2F device was registered!" redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
else else
@qr_code = build_qr_code @qr_code = build_qr_code
setup_u2f_registration setup_u2f_registration
...@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# Actual communication is performed using a Javascript API # Actual communication is performed using a Javascript API
def setup_u2f_registration def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new @u2f_registration ||= U2fRegistration.new
@registration_key_handles = current_user.u2f_registrations.pluck(:key_handle) @u2f_registrations = current_user.u2f_registrations
u2f = U2F::U2F.new(u2f_app_id) u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests registration_requests = u2f.registration_requests
sign_requests = u2f.authentication_requests(@registration_key_handles) sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
session[:challenges] = registration_requests.map(&:challenge) session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id, gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
register_requests: registration_requests, register_requests: registration_requests,
sign_requests: sign_requests }) sign_requests: sign_requests })
end end
def u2f_registration_params
params.require(:u2f_registration).permit(:device_response, :name)
end
end end
class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
end
end
...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
end end
def commit def commit
@commit ||= @project.commit(params[:id]) @noteable = @commit ||= @project.commit(params[:id])
end end
def pipelines def pipelines
......
class Projects::DiscussionsController < Projects::ApplicationController
before_action :module_enabled
before_action :merge_request
before_action :discussion
before_action :authorize_resolve_discussion!
def resolve
discussion.resolve!(current_user)
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
render json: {
resolved_by: discussion.resolved_by.try(:name),
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end
def unresolve
discussion.unresolve!
render json: {
discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
}
end
private
def merge_request
@merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id])
end
def discussion
@discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
end
def authorize_resolve_discussion!
access_denied! unless discussion.can_resolve?(current_user)
end
def module_enabled
render_404 unless @project.merge_requests_enabled
end
end
...@@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController ...@@ -27,6 +27,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController
@ci = true @ci = true
elsif auth_result.type == :oauth && !download_request? elsif auth_result.type == :oauth && !download_request?
# Not allowed # Not allowed
elsif auth_result.type == :missing_personal_token
render_missing_personal_token
return # Render above denied access, nothing left to do
else else
@user = auth_result.user @user = auth_result.user
end end
...@@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController ...@@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController
[nil, nil] [nil, nil]
end end
def render_missing_personal_token
render plain: "HTTP Basic: Access denied\n" \
"You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: 401
end
def repository def repository
_, suffix = project_id_with_suffix _, suffix = project_id_with_suffix
if suffix == '.wiki.git' if suffix == '.wiki.git'
......
...@@ -183,11 +183,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -183,11 +183,7 @@ class Projects::IssuesController < Projects::ApplicationController
protected protected
def issue def issue
@issue ||= begin @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
@project.issues.find_by!(iid: params[:id])
rescue ActiveRecord::RecordNotFound
redirect_old
end
end end
alias_method :subscribable_resource, :issue alias_method :subscribable_resource, :issue
alias_method :issuable, :issue alias_method :issuable, :issue
...@@ -232,7 +228,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -232,7 +228,6 @@ class Projects::IssuesController < Projects::ApplicationController
if issue if issue
redirect_to issue_path(issue) redirect_to issue_path(issue)
return
else else
raise ActiveRecord::RecordNotFound.new raise ActiveRecord::RecordNotFound.new
end end
......
...@@ -421,7 +421,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -421,7 +421,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
def merge_request def merge_request
@merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
end end
alias_method :subscribable_resource, :merge_request alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request alias_method :issuable, :merge_request
...@@ -475,12 +475,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -475,12 +475,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# :show, :diff, :commits, :builds. but not when request the data through AJAX # :show, :diff, :commits, :builds. but not when request the data through AJAX
def define_discussion_vars def define_discussion_vars
# Build a note object for comment form # Build a note object for comment form
@note = @project.notes.new(noteable: @noteable) @note = @project.notes.new(noteable: @merge_request)
@discussions = @noteable.mr_and_commit_notes. @discussions = @merge_request.discussions
inc_author_project_award_emoji.
fresh.
discussions
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
...@@ -514,7 +511,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -514,7 +511,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
} }
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
Banzai::NoteRenderer.render( Banzai::NoteRenderer.render(
@grouped_diff_discussions.values.flat_map(&:notes), @grouped_diff_discussions.values.flat_map(&:notes),
......
...@@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_read_note! before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create] before_action :authorize_create_note!, only: [:create]
before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
before_action :find_current_user_notes, only: [:index] before_action :find_current_user_notes, only: [:index]
def index def index
...@@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
end end
end end
def resolve
return render_404 unless note.resolvable?
note.resolve!(current_user)
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
discussion = note.discussion
render json: {
resolved_by: note.resolved_by.try(:name),
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
end
def unresolve
return render_404 unless note.resolvable?
note.unresolve!
discussion = note.discussion
render json: {
discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
}
end
private private
def note def note
...@@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController
id: note.id, id: note.id,
name: note.name name: note.name
} }
elsif note.valid? elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user) Banzai::NoteRenderer.render([note], @project, current_user)
attrs = { attrs = {
...@@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
} }
if note.diff_note? if note.diff_note?
discussion = Discussion.new([note]) discussion = note.to_discussion
attrs.merge!( attrs.merge!(
diff_discussion_html: diff_discussion_html(discussion), diff_discussion_html: diff_discussion_html(discussion),
...@@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
return access_denied! unless can?(current_user, :admin_note, note) return access_denied! unless can?(current_user, :admin_note, note)
end end
def authorize_resolve_note!
return access_denied! unless can?(current_user, :resolve_note, note)
end
def note_params def note_params
params.require(:note).permit( params.require(:note).permit(
:note, :noteable, :noteable_id, :noteable_type, :project_id, :note, :noteable, :noteable_id, :noteable_type, :project_id,
......
class Projects::TemplatesController < Projects::ApplicationController
before_action :authenticate_user!, :get_template_class
def show
template = @template_type.find(params[:key], project)
respond_to do |format|
format.json { render json: template.to_json }
end
end
private
def get_template_class
template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access
@template_type = template_types[params[:template_type]]
render json: [], status: 404 unless @template_type
end
end
...@@ -135,10 +135,22 @@ class ProjectsController < Projects::ApplicationController ...@@ -135,10 +135,22 @@ class ProjectsController < Projects::ApplicationController
end end
def autocomplete_sources def autocomplete_sources
note_type = params['type'] noteable =
note_id = params['type_id'] case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id, state: 'all').
execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all').
execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
else
nil
end
autocomplete = ::Projects::AutocompleteService.new(@project, current_user) autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = { @suggestions = {
emojis: Gitlab::AwardEmoji.urls, emojis: Gitlab::AwardEmoji.urls,
...@@ -146,7 +158,8 @@ class ProjectsController < Projects::ApplicationController ...@@ -146,7 +158,8 @@ class ProjectsController < Projects::ApplicationController
milestones: autocomplete.milestones, milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests, mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels, labels: autocomplete.labels,
members: participants members: participants,
commands: autocomplete.commands(noteable, params[:type])
} }
respond_to do |format| respond_to do |format|
......
...@@ -17,7 +17,7 @@ class TodosFinder ...@@ -17,7 +17,7 @@ class TodosFinder
attr_accessor :current_user, :params attr_accessor :current_user, :params
def initialize(current_user, params) def initialize(current_user, params = {})
@current_user = current_user @current_user = current_user
@params = params @params = params
end end
......
...@@ -32,6 +32,8 @@ module AppearancesHelper ...@@ -32,6 +32,8 @@ module AppearancesHelper
end end
def custom_icon(icon_name, size: 16) def custom_icon(icon_name, size: 16)
# We can't simply do the below, because there are some .erb SVGs.
# File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
render "shared/icons/#{icon_name}.svg", size: size render "shared/icons/#{icon_name}.svg", size: size
end end
end end
...@@ -11,17 +11,14 @@ module BlobHelper ...@@ -11,17 +11,14 @@ module BlobHelper
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless current_user return unless current_user
blob = project.repository.blob_at(ref, path) rescue nil blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob return unless blob
from_mr = options[:from_merge_request_id]
link_opts = {}
link_opts[:from_merge_request_id] = from_mr if from_mr
edit_path = namespace_project_edit_blob_path(project.namespace, project, edit_path = namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path), tree_join(ref, path),
link_opts) options[:link_opts])
if !on_top_of_branch?(project, ref) if !on_top_of_branch?(project, ref)
button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' } button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' }
...@@ -182,17 +179,42 @@ module BlobHelper ...@@ -182,17 +179,42 @@ module BlobHelper
} }
end end
def selected_template(issuable)
templates = issuable_templates(issuable)
params[:issuable_template] if templates.include?(params[:issuable_template])
end
def can_add_template?(issuable)
names = issuable_templates(issuable)
names.empty? && can?(current_user, :push_code, @project) && !@project.private?
end
def merge_request_template_names
@merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project)
end
def issue_template_names
@issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project)
end
def issuable_templates(issuable)
@issuable_templates ||=
if issuable.is_a?(Issue)
issue_template_names
elsif issuable.is_a?(MergeRequest)
merge_request_template_names
end
end
def ref_project
@ref_project ||= @target_project || @project
end
def gitignore_names def gitignore_names
@gitignore_names ||= @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names
Gitlab::Template::Gitignore.categories.keys.map do |k|
[k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }]
end.to_h
end end
def gitlab_ci_ymls def gitlab_ci_ymls
@gitlab_ci_ymls ||= @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names
Gitlab::Template::GitlabCiYml.categories.keys.map do |k|
[k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }]
end.to_h
end end
end end
...@@ -49,7 +49,7 @@ module NotesHelper ...@@ -49,7 +49,7 @@ module NotesHelper
} }
if use_legacy_diff_note if use_legacy_diff_note
discussion_id = LegacyDiffNote.build_discussion_id( discussion_id = LegacyDiffNote.discussion_id(
@comments_target[:noteable_type], @comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id], @comments_target[:noteable_id] || @comments_target[:commit_id],
line_code line_code
...@@ -60,7 +60,7 @@ module NotesHelper ...@@ -60,7 +60,7 @@ module NotesHelper
discussion_id: discussion_id discussion_id: discussion_id
) )
else else
discussion_id = DiffNote.build_discussion_id( discussion_id = DiffNote.discussion_id(
@comments_target[:noteable_type], @comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id], @comments_target[:noteable_id] || @comments_target[:commit_id],
position position
...@@ -81,10 +81,8 @@ module NotesHelper ...@@ -81,10 +81,8 @@ module NotesHelper
data = discussion.reply_attributes.merge(line_type: line_type) data = discussion.reply_attributes.merge(line_type: line_type)
content_tag(:div, class: "discussion-reply-holder") do button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply'
data: data, title: 'Add a reply'
end
end end
def preload_max_access_for_authors(notes, project) def preload_max_access_for_authors(notes, project)
......
...@@ -61,6 +61,13 @@ module Emails ...@@ -61,6 +61,13 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end end
def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@resolved_by = User.find(resolved_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
end
private private
def setup_merge_request_mail(merge_request_id, recipient_id) def setup_merge_request_mail(merge_request_id, recipient_id)
......
...@@ -291,6 +291,7 @@ class Ability ...@@ -291,6 +291,7 @@ class Ability
:create_merge_request, :create_merge_request,
:create_wiki, :create_wiki,
:push_code, :push_code,
:resolve_note,
:create_container_image, :create_container_image,
:update_container_image, :update_container_image,
:create_environment, :create_environment,
...@@ -481,7 +482,8 @@ class Ability ...@@ -481,7 +482,8 @@ class Ability
rules += [ rules += [
:read_note, :read_note,
:update_note, :update_note,
:admin_note :admin_note,
:resolve_note
] ]
end end
...@@ -489,6 +491,10 @@ class Ability ...@@ -489,6 +491,10 @@ class Ability
rules += project_abilities(user, note.project) rules += project_abilities(user, note.project)
end end
if note.for_merge_request? && note.noteable.author == user
rules << :resolve_note
end
rules rules
end end
......
...@@ -9,11 +9,13 @@ class DiffNote < Note ...@@ -9,11 +9,13 @@ class DiffNote < Note
validates :diff_line, presence: true validates :diff_line, presence: true
validates :line_code, presence: true, line_code: true validates :line_code, presence: true, line_code: true
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] } validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
validates :resolved_by, presence: true, if: :resolved?
validate :positions_complete validate :positions_complete
validate :verify_supported validate :verify_supported
after_initialize :ensure_original_discussion_id
before_validation :set_original_position, :update_position, on: :create before_validation :set_original_position, :update_position, on: :create
before_validation :set_line_code before_validation :set_line_code, :set_original_discussion_id
after_save :keep_around_commits after_save :keep_around_commits
class << self class << self
...@@ -30,14 +32,6 @@ class DiffNote < Note ...@@ -30,14 +32,6 @@ class DiffNote < Note
{ position: position.to_json } { position: position.to_json }
end end
def discussion_id
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
end
def original_discussion_id
@original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
end
def position=(new_position) def position=(new_position)
if new_position.is_a?(String) if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil new_position = JSON.parse(new_position) rescue nil
...@@ -72,10 +66,48 @@ class DiffNote < Note ...@@ -72,10 +66,48 @@ class DiffNote < Note
self.position.diff_refs == diff_refs self.position.diff_refs == diff_refs
end end
def resolvable?
!system? && for_merge_request?
end
def resolved?
return false unless resolvable?
self.resolved_at.present?
end
def resolve!(current_user)
return unless resolvable?
return if resolved?
self.resolved_at = Time.now
self.resolved_by = current_user
save!
end
def unresolve!
return unless resolvable?
return unless resolved?
self.resolved_at = nil
self.resolved_by = nil
save!
end
def discussion
return unless resolvable?
self.noteable.find_diff_discussion(self.discussion_id)
end
def to_discussion
Discussion.new([self])
end
private private
def supported? def supported?
!self.for_merge_request? || self.noteable.has_complete_diff_refs? for_commit? || self.noteable.has_complete_diff_refs?
end end
def noteable_diff_refs def noteable_diff_refs
...@@ -94,6 +126,26 @@ class DiffNote < Note ...@@ -94,6 +126,26 @@ class DiffNote < Note
self.line_code = self.position.line_code(self.project.repository) self.line_code = self.position.line_code(self.project.repository)
end end
def ensure_original_discussion_id
return unless self.persisted?
return if self.original_discussion_id
set_original_discussion_id
update_column(:original_discussion_id, self.original_discussion_id)
end
def set_original_discussion_id
self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
end
def build_discussion_id
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
end
def build_original_discussion_id
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
end
def update_position def update_position
return unless supported? return unless supported?
return if for_commit? return if for_commit?
......
class Discussion class Discussion
NUMBER_OF_TRUNCATED_DIFF_LINES = 16 NUMBER_OF_TRUNCATED_DIFF_LINES = 16
attr_reader :first_note, :notes attr_reader :first_note, :last_note, :notes
delegate :created_at, delegate :created_at,
:project, :project,
...@@ -18,6 +18,12 @@ class Discussion ...@@ -18,6 +18,12 @@ class Discussion
to: :first_note to: :first_note
delegate :resolved_at,
:resolved_by,
to: :last_resolved_note,
allow_nil: true
delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
def self.for_notes(notes) def self.for_notes(notes)
...@@ -30,13 +36,30 @@ class Discussion ...@@ -30,13 +36,30 @@ class Discussion
def initialize(notes) def initialize(notes)
@first_note = notes.first @first_note = notes.first
@last_note = notes.last
@notes = notes @notes = notes
end end
def last_resolved_note
return unless resolved?
@last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
end
def last_updated_at
last_note.created_at
end
def last_updated_by
last_note.author
end
def id def id
first_note.discussion_id first_note.discussion_id
end end
alias_method :to_param, :id
def diff_discussion? def diff_discussion?
first_note.diff_note? first_note.diff_note?
end end
...@@ -45,6 +68,50 @@ class Discussion ...@@ -45,6 +68,50 @@ class Discussion
notes.any?(&:legacy_diff_note?) notes.any?(&:legacy_diff_note?)
end end
def resolvable?
return @resolvable if defined?(@resolvable)
@resolvable = diff_discussion? && notes.any?(&:resolvable?)
end
def resolved?
return @resolved if defined?(@resolved)
@resolved = resolvable? && notes.none?(&:to_be_resolved?)
end
def resolved_notes
notes.select(&:resolved?)
end
def to_be_resolved?
resolvable? && !resolved?
end
def can_resolve?(current_user)
return false unless current_user
return false unless resolvable?
current_user == self.noteable.author ||
current_user.can?(:resolve_note, self.project)
end
def resolve!(current_user)
return unless resolvable?
notes.each do |note|
note.resolve!(current_user) if note.resolvable?
end
end
def unresolve!
return unless resolvable?
notes.each do |note|
note.unresolve! if note.resolvable?
end
end
def for_target?(target) def for_target?(target)
self.noteable == target && !diff_discussion? self.noteable == target && !diff_discussion?
end end
...@@ -55,8 +122,20 @@ class Discussion ...@@ -55,8 +122,20 @@ class Discussion
@active = first_note.active? @active = first_note.active?
end end
def collapsed?
return false unless diff_discussion?
if resolvable?
# New diff discussions only disappear once they are marked resolved
resolved?
else
# Old diff discussions disappear once they become outdated
!active?
end
end
def expanded? def expanded?
!diff_discussion? || active? !collapsed?
end end
def reply_attributes def reply_attributes
......
...@@ -8,8 +8,8 @@ class LegacyDiffNote < Note ...@@ -8,8 +8,8 @@ class LegacyDiffNote < Note
before_create :set_diff before_create :set_diff
class << self class << self
def build_discussion_id(noteable_type, noteable_id, line_code, active = true) def build_discussion_id(noteable_type, noteable_id, line_code)
[super(noteable_type, noteable_id), line_code, active].join("-") [super(noteable_type, noteable_id), line_code].join("-")
end end
end end
...@@ -21,10 +21,6 @@ class LegacyDiffNote < Note ...@@ -21,10 +21,6 @@ class LegacyDiffNote < Note
{ line_code: line_code } { line_code: line_code }
end end
def discussion_id
@discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
end
def project_repository def project_repository
if RequestStore.active? if RequestStore.active?
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository } RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
...@@ -119,4 +115,8 @@ class LegacyDiffNote < Note ...@@ -119,4 +115,8 @@ class LegacyDiffNote < Note
diffs = noteable.raw_diffs(Commit.max_diff_options) diffs = noteable.raw_diffs(Commit.max_diff_options)
diffs.find { |d| d.new_path == self.diff.new_path } diffs.find { |d| d.new_path == self.diff.new_path }
end end
def build_discussion_id
self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
end
end end
...@@ -439,6 +439,32 @@ class MergeRequest < ActiveRecord::Base ...@@ -439,6 +439,32 @@ class MergeRequest < ActiveRecord::Base
) )
end end
def discussions
@discussions ||= self.mr_and_commit_notes.
inc_relations_for_view.
fresh.
discussions
end
def diff_discussions
@diff_discussions ||= self.notes.diff_notes.discussions
end
def find_diff_discussion(discussion_id)
notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
return if notes.empty?
Discussion.new(notes)
end
def discussions_resolvable?
diff_discussions.any?(&:resolvable?)
end
def discussions_resolved?
discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
end
def hook_attrs def hook_attrs
attrs = { attrs = {
source: source_project.try(:hook_attrs), source: source_project.try(:hook_attrs),
......
...@@ -26,6 +26,9 @@ class Note < ActiveRecord::Base ...@@ -26,6 +26,9 @@ class Note < ActiveRecord::Base
belongs_to :author, class_name: "User" belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User" belongs_to :updated_by, class_name: "User"
# Only used by DiffNote, but defined here so that it can be used in `Note.includes`
belongs_to :resolved_by, class_name: "User"
has_many :todos, dependent: :destroy has_many :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy has_many :events, as: :target, dependent: :destroy
...@@ -61,7 +64,7 @@ class Note < ActiveRecord::Base ...@@ -61,7 +64,7 @@ class Note < ActiveRecord::Base
scope :fresh, ->{ order(created_at: :asc, id: :asc) } scope :fresh, ->{ order(created_at: :asc, id: :asc) }
scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author_project, ->{ includes(:project, :author) }
scope :inc_author, ->{ includes(:author) } scope :inc_author, ->{ includes(:author) }
scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) } scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) }
scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) } scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
scope :non_diff_notes, ->{ where(type: ['Note', nil]) } scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
...@@ -72,7 +75,9 @@ class Note < ActiveRecord::Base ...@@ -72,7 +75,9 @@ class Note < ActiveRecord::Base
project: [:project_members, { group: [:group_members] }]) project: [:project_members, { group: [:group_members] }])
end end
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id
after_save :keep_around_commit after_save :keep_around_commit
class << self class << self
...@@ -84,13 +89,18 @@ class Note < ActiveRecord::Base ...@@ -84,13 +89,18 @@ class Note < ActiveRecord::Base
[:discussion, noteable_type.try(:underscore), noteable_id].join("-") [:discussion, noteable_type.try(:underscore), noteable_id].join("-")
end end
def discussion_id(*args)
Digest::SHA1.hexdigest(build_discussion_id(*args))
end
def discussions def discussions
Discussion.for_notes(all) Discussion.for_notes(all)
end end
def grouped_diff_discussions def grouped_diff_discussions
notes = diff_notes.fresh.select(&:active?) active_notes = diff_notes.fresh.select(&:active?)
Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h Discussion.for_diff_notes(active_notes).
map { |d| [d.line_code, d] }.to_h
end end
# Searches for notes matching the given query. # Searches for notes matching the given query.
...@@ -135,13 +145,16 @@ class Note < ActiveRecord::Base ...@@ -135,13 +145,16 @@ class Note < ActiveRecord::Base
true true
end end
def discussion_id def resolvable?
@discussion_id ||= false
if for_merge_request? end
[:discussion, :note, id].join("-")
else def resolved?
self.class.build_discussion_id(noteable_type, noteable_id || commit_id) false
end end
def to_be_resolved?
resolvable? && !resolved?
end end
def max_attachment_size def max_attachment_size
...@@ -249,4 +262,26 @@ class Note < ActiveRecord::Base ...@@ -249,4 +262,26 @@ class Note < ActiveRecord::Base
def nullify_blank_line_code def nullify_blank_line_code
self.line_code = nil if self.line_code.blank? self.line_code = nil if self.line_code.blank?
end end
def ensure_discussion_id
return unless self.persisted?
return if self.discussion_id
set_discussion_id
update_column(:discussion_id, self.discussion_id)
end
def set_discussion_id
self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
end
def build_discussion_id
if for_merge_request?
# Notes on merge requests are always in a discussion of their own,
# so we generate a unique discussion ID.
[:discussion, :note, SecureRandom.hex].join("-")
else
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
end
end
end end
...@@ -3,18 +3,19 @@ ...@@ -3,18 +3,19 @@
class U2fRegistration < ActiveRecord::Base class U2fRegistration < ActiveRecord::Base
belongs_to :user belongs_to :user
def self.register(user, app_id, json_response, challenges) def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id) u2f = U2F::U2F.new(app_id)
registration = self.new registration = self.new
begin begin
response = U2F::RegisterResponse.load_from_json(json_response) response = U2F::RegisterResponse.load_from_json(params[:device_response])
registration_data = u2f.register!(challenges, response) registration_data = u2f.register!(challenges, response)
registration.update(certificate: registration_data.certificate, registration.update(certificate: registration_data.certificate,
key_handle: registration_data.key_handle, key_handle: registration_data.key_handle,
public_key: registration_data.public_key, public_key: registration_data.public_key,
counter: registration_data.counter, counter: registration_data.counter,
user: user) user: user,
name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError rescue JSON::ParserError, NoMethodError, ArgumentError
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
rescue U2F::Error => e rescue U2F::Error => e
......
...@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService ...@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService
end end
def filter_labels def filter_labels
if params[:add_label_ids].present? || params[:remove_label_ids].present? filter_labels_in_param(:add_label_ids)
params.delete(:label_ids) filter_labels_in_param(:remove_label_ids)
filter_labels_in_param(:label_ids)
filter_labels_in_param(:add_label_ids)
filter_labels_in_param(:remove_label_ids)
else
filter_labels_in_param(:label_ids)
end
end end
def filter_labels_in_param(key) def filter_labels_in_param(key)
...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService ...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService
params[key] = project.labels.where(id: params[key]).pluck(:id) params[key] = project.labels.where(id: params[key]).pluck(:id)
end end
def update_issuable(issuable, attributes) def process_label_ids(attributes, existing_label_ids: nil)
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids)
new_label_ids = existing_label_ids || label_ids || []
if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids
else
new_label_ids |= add_label_ids if add_label_ids
new_label_ids -= remove_label_ids if remove_label_ids
end
new_label_ids
end
def merge_slash_commands_into_params!(issuable)
description, command_params =
SlashCommands::InterpretService.new(project, current_user).
execute(params[:description], issuable)
params[:description] = description
params.merge!(command_params)
end
def create_issuable(issuable, attributes, label_ids:)
issuable.with_transaction_returning_status do issuable.with_transaction_returning_status do
add_label_ids = attributes.delete(:add_label_ids) if issuable.save
remove_label_ids = attributes.delete(:remove_label_ids) issuable.update_attributes(label_ids: label_ids)
end
end
end
issuable.label_ids |= add_label_ids if add_label_ids def create(issuable)
issuable.label_ids -= remove_label_ids if remove_label_ids merge_slash_commands_into_params!(issuable)
filter_params
params.delete(:state_event)
params[:author] ||= current_user
label_ids = process_label_ids(params)
issuable.assign_attributes(params)
before_create(issuable)
if params.present? && create_issuable(issuable, params, label_ids: label_ids)
after_create(issuable)
issuable.create_cross_references!(current_user)
execute_hooks(issuable)
end
issuable
end
issuable.assign_attributes(attributes.merge(updated_by: current_user)) def before_create(issuable)
# To be overridden by subclasses
end
def after_create(issuable)
# To be overridden by subclasses
end
issuable.save def update_issuable(issuable, attributes)
issuable.with_transaction_returning_status do
issuable.update(attributes.merge(updated_by: current_user))
end end
end end
def update(issuable) def update(issuable)
change_state(issuable) change_state(issuable)
change_subscription(issuable) change_subscription(issuable)
change_todo(issuable)
filter_params filter_params
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a old_mentioned_users = issuable.mentioned_users.to_a
params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids)
if params.present? && update_issuable(issuable, params) if params.present? && update_issuable(issuable, params)
issuable.reset_events_cache issuable.reset_events_cache
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService ...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService
end end
end end
def change_todo(issuable)
case params.delete(:todo_event)
when 'add'
todo_service.mark_todo(issuable, current_user)
when 'done'
todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
todo_service.mark_todos_as_done([todo], current_user) if todo
end
end
def has_changes?(issuable, old_labels: []) def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
......
module Issues module Issues
class CloseService < Issues::BaseService class CloseService < Issues::BaseService
def execute(issue, commit: nil, notifications: true, system_note: true) def execute(issue, commit: nil, notifications: true, system_note: true)
return issue unless can?(current_user, :update_issue, issue)
if project.jira_tracker? && project.jira_service.active if project.jira_tracker? && project.jira_service.active
project.jira_service.execute(commit, issue) project.jira_service.execute(commit, issue)
todo_service.close_issue(issue, current_user) todo_service.close_issue(issue, current_user)
......
module Issues module Issues
class CreateService < Issues::BaseService class CreateService < Issues::BaseService
def execute def execute
filter_params
label_params = params.delete(:label_ids)
@request = params.delete(:request) @request = params.delete(:request)
@api = params.delete(:api) @api = params.delete(:api)
@issue = project.issues.new(params)
@issue.author = params[:author] || current_user
@issue.spam = spam_service.check(@api) @issue = project.issues.new
if @issue.save create(@issue)
@issue.update_attributes(label_ids: label_params) end
notification_service.new_issue(@issue, current_user)
todo_service.new_issue(@issue, current_user) def before_create(issuable)
event_service.open_issue(@issue, current_user) issuable.spam = spam_service.check(@api)
user_agent_detail_service.create end
@issue.create_cross_references!(current_user)
execute_hooks(@issue, 'open')
end
@issue def after_create(issuable)
event_service.open_issue(issuable, current_user)
notification_service.new_issue(issuable, current_user)
todo_service.new_issue(issuable, current_user)
user_agent_detail_service.create
end end
private private
......
module Issues module Issues
class ReopenService < Issues::BaseService class ReopenService < Issues::BaseService
def execute(issue) def execute(issue)
return issue unless can?(current_user, :update_issue, issue)
if issue.reopen if issue.reopen
event_service.reopen_issue(issue, current_user) event_service.reopen_issue(issue, current_user)
create_note(issue) create_note(issue)
......
module MergeRequests module MergeRequests
class CloseService < MergeRequests::BaseService class CloseService < MergeRequests::BaseService
def execute(merge_request, commit = nil) def execute(merge_request, commit = nil)
return merge_request unless can?(current_user, :update_merge_request, merge_request)
# If we close MergeRequest we want to ignore validation # If we close MergeRequest we want to ignore validation
# so we can close broken one (Ex. fork project removed) # so we can close broken one (Ex. fork project removed)
merge_request.allow_broken = true merge_request.allow_broken = true
......
...@@ -7,26 +7,19 @@ module MergeRequests ...@@ -7,26 +7,19 @@ module MergeRequests
source_project = @project source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id] @project = Project.find(params[:target_project_id]) if params[:target_project_id]
filter_params params[:target_project_id] ||= source_project.id
label_params = params.delete(:label_ids)
force_remove_source_branch = params.delete(:force_remove_source_branch)
merge_request = MergeRequest.new(params) merge_request = MergeRequest.new
merge_request.source_project = source_project merge_request.source_project = source_project
merge_request.target_project ||= source_project merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
merge_request.author = current_user
merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
if merge_request.save create(merge_request)
merge_request.update_attributes(label_ids: label_params) end
event_service.open_mr(merge_request, current_user)
notification_service.new_merge_request(merge_request, current_user)
todo_service.new_merge_request(merge_request, current_user)
merge_request.create_cross_references!(current_user)
execute_hooks(merge_request)
end
merge_request def after_create(issuable)
event_service.open_mr(issuable, current_user)
notification_service.new_merge_request(issuable, current_user)
todo_service.new_merge_request(issuable, current_user)
end end
end end
end end
module MergeRequests module MergeRequests
class ReopenService < MergeRequests::BaseService class ReopenService < MergeRequests::BaseService
def execute(merge_request) def execute(merge_request)
return merge_request unless can?(current_user, :update_merge_request, merge_request)
if merge_request.reopen if merge_request.reopen
event_service.reopen_mr(merge_request, current_user) event_service.reopen_mr(merge_request, current_user)
create_note(merge_request) create_note(merge_request)
......
module MergeRequests
class ResolvedDiscussionNotificationService < MergeRequests::BaseService
def execute(merge_request)
return unless merge_request.discussions_resolved?
SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
notification_service.resolve_all_discussions(merge_request, current_user)
end
end
end
...@@ -11,10 +11,33 @@ module Notes ...@@ -11,10 +11,33 @@ module Notes
return noteable.create_award_emoji(note.award_emoji_name, current_user) return noteable.create_award_emoji(note.award_emoji_name, current_user)
end end
if note.save # We execute commands (extracted from `params[:note]`) on the noteable
# **before** we save the note because if the note consists of commands
# only, there is no need be create a note!
slash_commands_service = SlashCommandsService.new(project, current_user)
if slash_commands_service.supported?(note)
content, command_params = slash_commands_service.extract_commands(note)
only_commands = content.empty?
note.note = content
end
if !only_commands && note.save
# Finish the harder work in the background # Finish the harder work in the background
NewNoteWorker.perform_in(2.seconds, note.id, params) NewNoteWorker.perform_in(2.seconds, note.id, params)
TodoService.new.new_note(note, current_user) todo_service.new_note(note, current_user)
end
if command_params && command_params.any?
slash_commands_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset
# when #save is called
if only_commands
note.errors.add(:commands_only, 'Your commands have been executed!')
end
end end
note note
......
module Notes
class SlashCommandsService < BaseService
UPDATE_SERVICES = {
'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService
}
def supported?(note)
noteable_update_service(note) &&
can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable)
end
def extract_commands(note)
return [note.note, {}] unless supported?(note)
SlashCommands::InterpretService.new(project, current_user).
execute(note.note, note.noteable)
end
def execute(command_params, note)
return if command_params.empty?
return unless supported?(note)
noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
end
private
def noteable_update_service(note)
UPDATE_SERVICES[note.noteable_type]
end
end
end
...@@ -161,6 +161,14 @@ class NotificationService ...@@ -161,6 +161,14 @@ class NotificationService
approve_mr_email(merge_request, merge_request.target_project, current_user) approve_mr_email(merge_request, merge_request.target_project, current_user)
end end
def resolve_all_discussions(merge_request, current_user)
recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
recipients.each do |recipient|
mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
end
end
# Notify new user with email after creation # Notify new user with email after creation
def new_user(user, token = nil) def new_user(user, token = nil)
# Don't email omniauth created users # Don't email omniauth created users
......
module Projects module Projects
class AutocompleteService < BaseService class AutocompleteService < BaseService
def issues def issues
@project.issues.visible_to_user(current_user).opened.select([:iid, :title]) IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def milestones def milestones
...@@ -9,11 +9,34 @@ module Projects ...@@ -9,11 +9,34 @@ module Projects
end end
def merge_requests def merge_requests
@project.merge_requests.opened.select([:iid, :title]) MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def labels def labels
@project.labels.select([:title, :color]) @project.labels.select([:title, :color])
end end
def commands(noteable, type)
noteable ||=
case type
when 'Issue'
@project.issues.build
when 'MergeRequest'
@project.merge_requests.build
end
return [] unless noteable && noteable.is_a?(Issuable)
opts = {
project: project,
issuable: noteable,
current_user: current_user
}
SlashCommands::InterpretService.command_definitions.map do |definition|
next unless definition.available?(opts)
definition.to_h(opts)
end.compact
end
end end
end end
module Projects module Projects
class ParticipantsService < BaseService class ParticipantsService < BaseService
def execute(noteable_type, noteable_id) attr_reader :noteable
@noteable_type = noteable_type
@noteable_id = noteable_id def execute(noteable)
@noteable = noteable
project_members = sorted(project.team.members) project_members = sorted(project.team.members)
participants = target_owner + participants_in_target + all_members + groups + project_members participants = noteable_owner + participants_in_noteable + all_members + groups + project_members
participants.uniq participants.uniq
end end
def target def noteable_owner
@target ||= return [] unless noteable && noteable.author.present?
case @noteable_type
when "Issue"
project.issues.find_by_iid(@noteable_id)
when "MergeRequest"
project.merge_requests.find_by_iid(@noteable_id)
when "Commit"
project.commit(@noteable_id)
else
nil
end
end
def target_owner
return [] unless target && target.author.present?
[{ [{
name: target.author.name, name: noteable.author.name,
username: target.author.username username: noteable.author.username
}] }]
end end
def participants_in_target def participants_in_noteable
return [] unless target return [] unless noteable
users = target.participants(current_user) users = noteable.participants(current_user)
sorted(users) sorted(users)
end end
......
module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
attr_reader :issuable
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
def execute(content, issuable)
@issuable = issuable
@updates = {}
opts = {
issuable: issuable,
current_user: current_user,
project: project
}
content, commands = extractor.extract_commands(content, opts)
commands.each do |name, arg|
definition = self.class.command_definitions_by_name[name.to_sym]
next unless definition
definition.execute(self, opts, arg)
end
[content, @updates]
end
private
def extractor
Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
end
desc do
"Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
condition do
issuable.persisted? &&
issuable.open? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :close do
@updates[:state_event] = 'close'
end
desc do
"Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
condition do
issuable.persisted? &&
issuable.closed? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :reopen do
@updates[:state_event] = 'reopen'
end
desc 'Change title'
params '<New title>'
condition do
issuable.persisted? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :title do |title_param|
@updates[:title] = title_param
end
desc 'Assign'
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :assign do |assignee_param|
user = extract_references(assignee_param, :user).first
user ||= User.find_by(username: assignee_param)
@updates[:assignee_id] = user.id if user
end
desc 'Remove assignee'
condition do
issuable.persisted? &&
issuable.assignee_id? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :unassign do
@updates[:assignee_id] = nil
end
desc 'Set milestone'
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.milestones.active.any?
end
command :milestone do |milestone_param|
milestone = extract_references(milestone_param, :milestone).first
milestone ||= project.milestones.find_by(title: milestone_param.strip)
@updates[:milestone_id] = milestone.id if milestone
end
desc 'Remove milestone'
condition do
issuable.persisted? &&
issuable.milestone_id? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_milestone do
@updates[:milestone_id] = nil
end
desc 'Add label(s)'
params '~label1 ~"label 2"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.labels.any?
end
command :label do |labels_param|
label_ids = find_label_ids(labels_param)
@updates[:add_label_ids] = label_ids unless label_ids.empty?
end
desc 'Remove all or specific label(s)'
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
issuable.labels.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :unlabel do |labels_param = nil|
if labels_param.present?
label_ids = find_label_ids(labels_param)
@updates[:remove_label_ids] = label_ids unless label_ids.empty?
else
@updates[:label_ids] = []
end
end
desc 'Replace all label(s)'
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
issuable.labels.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :relabel do |labels_param|
label_ids = find_label_ids(labels_param)
@updates[:label_ids] = label_ids unless label_ids.empty?
end
desc 'Add a todo'
condition do
issuable.persisted? &&
!TodoService.new.todo_exist?(issuable, current_user)
end
command :todo do
@updates[:todo_event] = 'add'
end
desc 'Mark todo as done'
condition do
issuable.persisted? &&
TodoService.new.todo_exist?(issuable, current_user)
end
command :done do
@updates[:todo_event] = 'done'
end
desc 'Subscribe'
condition do
issuable.persisted? &&
!issuable.subscribed?(current_user)
end
command :subscribe do
@updates[:subscription_event] = 'subscribe'
end
desc 'Unsubscribe'
condition do
issuable.persisted? &&
issuable.subscribed?(current_user)
end
command :unsubscribe do
@updates[:subscription_event] = 'unsubscribe'
end
desc 'Set due date'
params '<in 2 days | this Friday | December 31st>'
condition do
issuable.respond_to?(:due_date) &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :due do |due_date_param|
due_date = Chronic.parse(due_date_param).try(:to_date)
@updates[:due_date] = due_date if due_date
end
desc 'Remove due date'
condition do
issuable.persisted? &&
issuable.respond_to?(:due_date) &&
issuable.due_date? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :remove_due_date do
@updates[:due_date] = nil
end
# This is a dummy command, so that it appears in the autocomplete commands
desc 'CC'
params '@user'
command :cc
def find_label_ids(labels_param)
label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id)
label_ids_by_reference | labels_ids_by_name
end
def extract_references(arg, type)
ext = Gitlab::ReferenceExtractor.new(project, current_user)
ext.analyze(arg, author: current_user)
ext.references(type)
end
end
end
...@@ -158,6 +158,12 @@ module SystemNoteService ...@@ -158,6 +158,12 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body) create_note(noteable: noteable, project: project, author: author, note: body)
end end
def self.resolve_all_discussions(merge_request, project, author)
body = "Resolved all discussions"
create_note(noteable: merge_request, project: project, author: author, note: body)
end
# Called when the title of a Noteable is changed # Called when the title of a Noteable is changed
# #
# noteable - Noteable object that responds to `title` # noteable - Noteable object that responds to `title`
......
...@@ -167,6 +167,10 @@ class TodoService ...@@ -167,6 +167,10 @@ class TodoService
create_todos(current_user, attributes) create_todos(current_user, attributes)
end end
def todo_exist?(issuable, current_user)
TodosFinder.new(current_user).execute.exists?(target: issuable)
end
private private
def create_todos(users, attributes) def create_todos(users, attributes)
......
%tr.notes_holder - expanded = local_assigns.fetch(:expanded, true)
%tr.notes_holder{class: ('hide' unless expanded)}
%td.notes_line{ colspan: 2 } %td.notes_line{ colspan: 2 }
%td.notes_content %td.notes_content
%ul.notes{ data: { discussion_id: discussion.id } } .content
= render partial: "projects/notes/note", collection: discussion.notes, as: :note = render "discussions/notes", discussion: discussion
= link_to_reply_discussion(discussion)
...@@ -7,8 +7,11 @@ ...@@ -7,8 +7,11 @@
.diff-content.code.js-syntax-highlight .diff-content.code.js-syntax-highlight
%table %table
- discussion.truncated_diff_lines.each do |line| - discussions = { discussion.line_code => discussion }
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true = render partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
- if discussion.for_line?(line) as: :line,
= render "discussions/diff_discussion", discussion: discussion locals: { diff_file: diff_file,
discussions: discussions,
discussion_expanded: true,
plain: true }
...@@ -5,8 +5,17 @@ ...@@ -5,8 +5,17 @@
= link_to user_path(discussion.author) do = link_to user_path(discussion.author) do
= image_tag avatar_icon(discussion.author), class: "avatar s40" = image_tag avatar_icon(discussion.author), class: "avatar s40"
.timeline-content .timeline-content
.discussion.js-toggle-container{ class: discussion.id } .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
.discussion-header .discussion-header
.discussion-actions
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
- if expanded
= icon("chevron-up")
- else
= icon("chevron-down")
Toggle discussion
= link_to_member(@project, discussion.author, avatar: false) = link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light .inline.discussion-headline-light
...@@ -29,17 +38,11 @@ ...@@ -29,17 +38,11 @@
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
.discussion-actions = render "discussions/headline", discussion: discussion
= link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
- if expanded
= icon("chevron-up")
- else
= icon("chevron-down")
Toggle discussion
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) } .discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
- if discussion.diff_discussion? && discussion.diff_file - if discussion.diff_discussion? && discussion.diff_file
= render "discussions/diff_with_notes", discussion: discussion = render "discussions/diff_with_notes", discussion: discussion
- else - else
= render "discussions/notes", discussion: discussion .panel.panel-default
= render "discussions/notes", discussion: discussion
- if discussion.resolved?
.discussion-headline-light.js-discussion-headline
Resolved
- if discussion.resolved_by
by
= link_to_member(@project, discussion.resolved_by, avatar: false)
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
- elsif discussion.last_updated_at != discussion.created_at
.discussion-headline-light.js-discussion-headline
Last updated
- if discussion.last_updated_by
by
= link_to_member(@project, discussion.last_updated_by, avatar: false)
= time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom")
- discussion = local_assigns.fetch(:discussion, nil)
- if current_user
%jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
.btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
%button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
title: "Jump to next unresolved discussion",
"aria-label" => "Jump to next unresolved discussion",
data: { container: "body" } }
= custom_icon("next_discussion")
.panel.panel-default %ul.notes{ data: { discussion_id: discussion.id } }
.notes{ data: { discussion_id: discussion.id } } = render partial: "projects/notes/note", collection: discussion.notes, as: :note
%ul.notes.timeline
= render partial: "projects/notes/note", collection: discussion.notes, as: :note - if current_user
= link_to_reply_discussion(discussion) .discussion-reply-holder
- if discussion.diff_discussion?
- line_type = local_assigns.fetch(:line_type, nil)
.btn-group-justified.discussion-with-resolve-btn{ role: "group" }
.btn-group{ role: "group" }
= link_to_reply_discussion(discussion, line_type)
= render "discussions/resolve_all", discussion: discussion
= render "discussions/jump_to_next", discussion: discussion
- else
= link_to_reply_discussion(discussion)
%tr.notes_holder - expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
%tr.notes_holder{class: ('hide' unless expanded)}
- if discussion_left - if discussion_left
%td.notes_line.old %td.notes_line.old
%td.notes_content.parallel.old %td.notes_content.parallel.old
%ul.notes{ data: { discussion_id: discussion_left.id } } .content{class: ('hide' unless discussion_left.expanded?)}
= render partial: "projects/notes/note", collection: discussion_left.notes, as: :note = render "discussions/notes", discussion: discussion_left, line_type: 'old'
= link_to_reply_discussion(discussion_left, 'old')
- else - else
%td.notes_line.old= "" %td.notes_line.old= ""
%td.notes_content.parallel.old= "" %td.notes_content.parallel.old
.content
- if discussion_right - if discussion_right
%td.notes_line.new %td.notes_line.new
%td.notes_content.parallel.new %td.notes_content.parallel.new
%ul.notes{ data: { discussion_id: discussion_right.id } } .content{class: ('hide' unless discussion_right.expanded?)}
= render partial: "projects/notes/note", collection: discussion_right.notes, as: :note = render "discussions/notes", discussion: discussion_right, line_type: 'new'
= link_to_reply_discussion(discussion_right, 'new')
- else - else
%td.notes_line.new= "" %td.notes_line.new= ""
%td.notes_content.parallel.new= "" %td.notes_content.parallel.new
.content
- if discussion.for_merge_request?
%resolve-discussion-btn{ ":namespace-path" => "'#{discussion.project.namespace.path}'",
":project-path" => "'#{discussion.project.path}'",
":discussion-id" => "'#{discussion.id}'",
":merge-request-id" => discussion.noteable.iid,
":can-resolve" => discussion.can_resolve?(current_user),
"inline-template" => true }
.btn-group{ role: "group", "v-if" => "showButton" }
%button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading" }
= icon("spinner spin", "v-show" => "loading")
{{ buttonText }}
- project = @target_project || @project - project = @target_project || @project
- noteable_class = @noteable.class if @noteable.present? - noteable_type = @noteable.class if @noteable.present?
:javascript :javascript
GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}" GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.cachedData = undefined;
GitLab.GfmAutoComplete.setup(); GitLab.GfmAutoComplete.setup();
...@@ -75,8 +75,7 @@ ...@@ -75,8 +75,7 @@
- blob = diff_file.blob - blob = diff_file.blob
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
%table.code.white %table.code.white
- diff_file.highlighted_diff_lines.each do |line| = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
= render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
- else - else
No preview for this file type No preview for this file type
%br %br
%p
All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
...@@ -7,6 +7,10 @@ ...@@ -7,6 +7,10 @@
= page_title = page_title
%p %p
You can generate a personal access token for each application you use that needs access to the GitLab API. You can generate a personal access token for each application you use that needs access to the GitLab API.
%p
You can also use personal access tokens to authenticate against Git over HTTP.
They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
.col-lg-9 .col-lg-9
- if flash[:personal_access_token] - if flash[:personal_access_token]
......
...@@ -60,13 +60,38 @@ ...@@ -60,13 +60,38 @@
two-factor authentication app before a U2F device. That way you'll always be able to two-factor authentication app before a U2F device. That way you'll always be able to
log in - even when you're using an unsupported browser. log in - even when you're using an unsupported browser.
.col-lg-9 .col-lg-9
%p
- if @registration_key_handles.present?
= icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab."
- if @u2f_registration.errors.present? - if @u2f_registration.errors.present?
= form_errors(@u2f_registration) = form_errors(@u2f_registration)
= render "u2f/register" = render "u2f/register"
%hr
%h5 U2F Devices (#{@u2f_registrations.length})
- if @u2f_registrations.present?
.table-responsive
%table.table.table-bordered.u2f-registrations
%colgroup
%col{ width: "50%" }
%col{ width: "30%" }
%col{ width: "20%" }
%thead
%tr
%th Name
%th Registered On
%th
%tbody
- @u2f_registrations.each do |registration|
%tr
%td= registration.name.presence || "<no name set>"
%td= registration.created_at.to_date.to_s(:medium)
%td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
- else
.settings-message.text-center
You don't have any U2F devices registered yet.
- if two_factor_skippable? - if two_factor_skippable?
:javascript :javascript
var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>"; var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
......
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop .zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area' - classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f - if defined?(f) && f
= f.text_area attr, class: classes, placeholder: placeholder = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
- else - else
= text_area_tag attr, nil, class: classes, placeholder: placeholder = text_area_tag attr, nil, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
......
...@@ -10,10 +10,9 @@ ...@@ -10,10 +10,9 @@
\ \
- if editable_diff?(diff_file) - if editable_diff?(diff_file)
= edit_blob_link(@merge_request.source_project, - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {}
@merge_request.source_branch, diff_file.new_path, = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
from_merge_request_id: @merge_request.id, blob: blob, link_opts: link_opts)
skip_visible_check: true)
= view_file_btn(diff_commit.id, diff_file.new_path, project) = view_file_btn(diff_commit.id, diff_file.new_path, project)
......
- email = local_assigns.fetch(:email, false)
- plain = local_assigns.fetch(:plain, false) - plain = local_assigns.fetch(:plain, false)
- type = line.type - type = line.type
- line_code = diff_file.line_code(line) unless plain - line_code = diff_file.line_code(line)
%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } } %tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
- case type - case type
- when 'match' - when 'match'
...@@ -22,4 +23,15 @@ ...@@ -22,4 +23,15 @@
= link_text = link_text
- else - else
%a{href: "##{line_code}", data: { linenumber: link_text }} %a{href: "##{line_code}", data: { linenumber: link_text }}
%td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type) %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
- if email
%pre= diff_line_content(line.text, type)
- else
= diff_line_content(line.text, type)
- discussions = local_assigns.fetch(:discussions, nil)
- if discussions && !line.meta?
- discussion = discussions[line_code]
- if discussion
- discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
= render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
...@@ -5,15 +5,12 @@ ...@@ -5,15 +5,12 @@
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } %table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
- last_line = 0 - last_line = 0
- diff_file.highlighted_diff_lines.each do |line| - discussions = @grouped_diff_discussions unless @diff_notes_disabled
- last_line = line.new_pos = render partial: "projects/diffs/line",
= render "projects/diffs/line", line: line, diff_file: diff_file collection: diff_file.highlighted_diff_lines,
as: :line,
- unless @diff_notes_disabled locals: { diff_file: diff_file, discussions: discussions }
- line_code = diff_file.line_code(line)
- discussion = @grouped_diff_discussions[line_code] if line_code
- if discussion
= render "discussions/diff_discussion", discussion: discussion
- last_line = diff_file.highlighted_diff_lines.last.new_pos
- if !diff_file.new_file && last_line > 0 - if !diff_file.new_file && last_line > 0
= diff_match_line last_line, last_line, bottom: true = diff_match_line last_line, last_line, bottom: true
...@@ -4,5 +4,8 @@ ...@@ -4,5 +4,8 @@
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.closed? - if @merge_request.closed?
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
%comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { namespace_path: "#{@merge_request.project.namespace.path}", project_path: "#{@merge_request.project.path}" } }
{{ buttonText }}
#notes= render "projects/notes/notes_with_form" #notes= render "projects/notes/notes_with_form"
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description - page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes - page_card_attributes @merge_request.card_attributes
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
- if diff_view == :parallel - if diff_view == :parallel
- fluid_layout true - fluid_layout true
...@@ -65,8 +67,18 @@ ...@@ -65,8 +67,18 @@
= link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
Changes Changes
%span.badge= @merge_request.diff_size %span.badge= @merge_request.diff_size
%li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
%resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
.line-resolve-all{ "v-show" => "discussionCount > 0",
":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
%span.line-resolve-btn.is-disabled{ type: "button",
":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
= render "shared/icons/icon_status_success.svg"
%span.line-resolve-text
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved
= render "discussions/jump_to_next"
.tab-content .tab-content#diff-notes-app
#notes.notes.tab-pane.voting_notes #notes.notes.tab-pane.voting_notes
.content-block.content-block-small.oneline-block .content-block.content-block-small.oneline-block
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
......
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f| = form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view = hidden_field_tag :view, diff_view
= hidden_field_tag :line_type = hidden_field_tag :line_type
= note_target_fields(@note) = note_target_fields(@note)
...@@ -10,8 +10,12 @@ ...@@ -10,8 +10,12 @@
= f.hidden_field :position = f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..." = render 'projects/zen', f: f,
= render 'projects/notes/hints' attr: :note,
classes: 'note-textarea js-note-text',
placeholder: "Write a comment or drag your files here...",
supports_slash_commands: true
= render 'projects/notes/hints', supports_slash_commands: true
.error-alert .error-alert
.note-form-actions.clearfix .note-form-actions.clearfix
......
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.comment-toolbar.clearfix .comment-toolbar.clearfix
.toolbar-text .toolbar-text
Styling with Styling with
= link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1
is supported - if supports_slash_commands
and
= link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1
are
- else
is
supported
%button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon') = icon('file-image-o', class: 'toolbar-button-icon')
Attach a file Attach a file
\ No newline at end of file
- return unless note.author - return unless note.author
- return if note.cross_reference_not_visible_for?(current_user) - return if note.cross_reference_not_visible_for?(current_user)
- can_resolve = can?(current_user, :resolve_note, note)
- note_editable = note_editable?(note) - note_editable = note_editable?(note)
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} } %li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} }
...@@ -16,19 +17,48 @@ ...@@ -16,19 +17,48 @@
commented commented
%a{ href: "##{dom_id(note)}" } %a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
.note-actions - unless note.system?
- access = note_max_access_for_user(note) .note-actions
- if access and not note.system - access = note_max_access_for_user(note)
%span.note-role.hidden-xs= access - if access
- if current_user and not note.system %span.note-role.hidden-xs= access
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
= icon('spinner spin') - if note.resolvable?
= icon('smile-o') %resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'",
- if note_editable ":project-path" => "'#{note.project.path}'",
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do ":discussion-id" => "'#{note.discussion_id}'",
= icon('pencil') ":note-id" => note.id,
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do ":resolved" => note.resolved?,
= icon('trash-o') ":can-resolve" => can_resolve,
":resolved-by" => "'#{note.resolved_by.try(:name)}'",
"v-show" => "#{can_resolve || note.resolved?}",
"inline-template" => true,
"v-ref:note_#{note.id}" => true }
.note-action-button
= icon("spin spinner", "v-show" => "loading")
%button.line-resolve-btn{ type: "button",
class: ("is-disabled" unless can_resolve),
":class" => "{ 'is-active': isResolved }",
":aria-label" => "buttonText",
"@click" => "resolve",
":title" => "buttonText",
"v-show" => "!loading",
"v-el:button" => true }
= render "shared/icons/icon_status_success.svg"
- if current_user
- if note.emoji_awardable?
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
= icon('spinner spin')
= icon('smile-o')
- if note_editable
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
= icon('pencil')
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
= icon('trash-o')
.note-body{class: note_editable ? 'js-task-list-container' : ''} .note-body{class: note_editable ? 'js-task-list-container' : ''}
.note-text.md .note-text.md
= preserve do = preserve do
......
<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>
...@@ -67,7 +67,7 @@ ...@@ -67,7 +67,7 @@
.filter-item.inline .filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
.filter-item.inline.labels-filter .filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
.filter-item.inline .filter-item.inline
= dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
%ul %ul
......
...@@ -2,7 +2,22 @@ ...@@ -2,7 +2,22 @@
.form-group .form-group
= f.label :title, class: 'control-label' = f.label :title, class: 'control-label'
.col-sm-10
- issuable_template_names = issuable_templates(issuable)
- if issuable_template_names.any?
.col-sm-3.col-lg-2
.js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } }
- title = selected_template(issuable) || "Choose a template"
= dropdown_tag(title, options: { toggle_class: 'js-issuable-selector',
title: title, filter: true, placeholder: 'Filter', footer_content: true,
data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do
%ul.dropdown-footer-list
%li
%a.reset-template
Reset template
%div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' }
= f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off',
class: 'form-control pad', required: true class: 'form-control pad', required: true
...@@ -23,6 +38,13 @@ ...@@ -23,6 +38,13 @@
to prevent a to prevent a
%strong Work In Progress %strong Work In Progress
merge request from being merged before it's ready. merge request from being merged before it's ready.
- if can_add_template?(issuable)
%p.help-block
Add
= link_to "issuable templates", help_page_path('workflow/description_templates')
to help your contributors communicate effectively!
.form-group.detail-page-description .form-group.detail-page-description
= f.label :description, 'Description', class: 'control-label' = f.label :description, 'Description', class: 'control-label'
.col-sm-10 .col-sm-10
...@@ -30,8 +52,9 @@ ...@@ -30,8 +52,9 @@
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, = render 'projects/zen', f: f, attr: :description,
classes: 'note-textarea', classes: 'note-textarea',
placeholder: "Write a comment or drag your files here..." placeholder: "Write a comment or drag your files here...",
= render 'projects/notes/hints' supports_slash_commands: !issuable.persisted?
= render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
.clearfix .clearfix
.error-alert .error-alert
......
...@@ -28,10 +28,15 @@ ...@@ -28,10 +28,15 @@
%script#js-register-u2f-registered{ type: "text/template" } %script#js-register-u2f-registered{ type: "text/template" }
%div.row.append-bottom-10 %div.row.append-bottom-10
%p Your device was successfully set up! Click this button to register with the GitLab server. .col-md-12
= form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do %p Your device was successfully set up! Give it a name and register it with the GitLab server.
= hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response" = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
= submit_tag "Register U2F Device", class: "btn btn-success" .row.append-bottom-10
.col-md-3
= text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name"
.col-md-3
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag "Register U2F Device", class: "btn btn-success"
:javascript :javascript
var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f); var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment