Merge remote-tracking branch 'upstream/master' into wall-clock-time-for-showing-pipeline
* upstream/master: (554 commits) Fix expansion of discussions in diff Improve performance of MR show page Fix jumping between discussions on changes tab Update doorkeeper to 4.2.0 Fix MR note discussion ID Handle legacy sort order values Refactor `find_for_git_client` and its related methods. Remove right margin on Jump button icon Fix bug causing “Jump to discussion” button not to show Small refactor and syntax fixes. Removed unnecessary service for user retrieval and improved API error message. Added documentation and CHANGELOG item Added checks for 2FA to the API `/sessions` endpoint and the Resource Owner Password Credentials flow. Fix behavior around commands with optional arguments Fix behavior of label_ids and add/remove_label_ids Remove unneeded aliases Do not expose projects on deployments Incorporate feedback Docs for API endpoints Expose project for environments ...
This commit is contained in:
commit
f8496a33b3
28
CHANGELOG
28
CHANGELOG
|
@ -5,10 +5,13 @@ v 8.11.0 (unreleased)
|
|||
- Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar)
|
||||
- Ability to specify branches for Pivotal Tracker integration (Egor Lynko)
|
||||
- Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres)
|
||||
- Add delimiter to project stars and forks count (ClemMakesApps)
|
||||
- Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres)
|
||||
- Fix the title of the toggle dropdown button. !5515 (herminiotorres)
|
||||
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
|
||||
- Update to Ruby 2.3.1. !4948
|
||||
- Add Issues Board !5548
|
||||
- Allow resolving merge conflicts in the UI !5479
|
||||
- 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)
|
||||
- Convert switch icon into icon font (ClemMakesApps)
|
||||
|
@ -16,7 +19,9 @@ v 8.11.0 (unreleased)
|
|||
- API: List access requests, request access, approve, and deny access requests to a project or a group. !4833
|
||||
- Use long options for curl examples in documentation !5703 (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
|
||||
- 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)
|
||||
- Fix CI status icon link underline (ClemMakesApps)
|
||||
- The Repository class is now instrumented
|
||||
|
@ -25,6 +30,9 @@ v 8.11.0 (unreleased)
|
|||
- Expand commit message width in repo view (ClemMakesApps)
|
||||
- Cache highlighted diff lines for merge requests
|
||||
- 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'
|
||||
- Show wall clock time when showing a pipeline. !5734
|
||||
- Show member roles to all users on members page
|
||||
|
@ -32,6 +40,7 @@ v 8.11.0 (unreleased)
|
|||
- Fix awardable button mutuality loading spinners (ClemMakesApps)
|
||||
- Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable
|
||||
- Optimize maximum user access level lookup in loading of notes
|
||||
- Send notification emails to users newly mentioned in issue and MR edits !5800
|
||||
- Add "No one can push" as an option for protected branches. !5081
|
||||
- Improve performance of AutolinkFilter#text_parse by using XPath
|
||||
- Add experimental Redis Sentinel support !1877
|
||||
|
@ -43,12 +52,16 @@ v 8.11.0 (unreleased)
|
|||
- 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
|
||||
- Show deployment status on merge requests with external URLs
|
||||
- Clean up unused routes (Josef Strzibny)
|
||||
- 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)
|
||||
- Optimize generating of cache keys for issues and notes
|
||||
- Fix repository push email formatting in Outlook
|
||||
- Improve performance of syntax highlighting Markdown code blocks
|
||||
- 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
|
||||
|
@ -57,9 +70,12 @@ v 8.11.0 (unreleased)
|
|||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Add archived badge to project list !5798
|
||||
- Add simple identifier to public SSH keys (muteor)
|
||||
|
@ -77,6 +93,8 @@ v 8.11.0 (unreleased)
|
|||
- Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
|
||||
- Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
|
||||
- Add pipeline events hook
|
||||
- Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
|
||||
- Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
|
||||
- Bump gitlab_git to speedup DiffCollection iterations
|
||||
- Rewrite description of a blocked user in admin settings. (Elias Werberich)
|
||||
- Make branches sortable without push permission !5462 (winniehell)
|
||||
|
@ -91,14 +109,19 @@ v 8.11.0 (unreleased)
|
|||
- Allow branch names ending with .json for graph and network page !5579 (winniehell)
|
||||
- Add the `sprockets-es6` gem
|
||||
- Improve OAuth2 client documentation (muteor)
|
||||
- Fix diff comments inverted toggle bug (ClemMakesApps)
|
||||
- Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska)
|
||||
- Profile requests when a header is passed
|
||||
- Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab.
|
||||
- Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible
|
||||
- Add commit stats in commit api. !5517 (dixpac)
|
||||
- Add CI configuration button on project page
|
||||
- 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)
|
||||
- The performance of the project dropdown used for moving issues has been improved
|
||||
- Fix skip_repo parameter being ignored when destroying a namespace
|
||||
- Add all builds into stage/job dropdowns on builds page
|
||||
- Change requests_profiles resource constraint to catch virtually any file
|
||||
- Bump gitlab_git to lazy load compare commits
|
||||
- Reduce number of queries made for merge_requests/:id/diffs
|
||||
|
@ -111,15 +134,20 @@ v 8.11.0 (unreleased)
|
|||
- Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker
|
||||
- Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko)
|
||||
- Adds support for pending invitation project members importing projects
|
||||
- Add pipeline visualization/graph on pipeline page
|
||||
- Update devise initializer to turn on changed password notification emails. !5648 (tombell)
|
||||
- Avoid to show the original password field when password is automatically set. !5712 (duduribeiro)
|
||||
- Fix importing GitLab projects with an invalid MR source project
|
||||
- Sort folders with submodules in Files view !5521
|
||||
- Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0
|
||||
- Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
|
||||
- Add pipelines tab to merge requests
|
||||
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter
|
||||
- Speed up todos queries by limiting the projects set we join with
|
||||
- Ensure file editing in UI does not overwrite commited changes without warning user
|
||||
- Eliminate unneeded calls to Repository#blob_at when listing commits with no path
|
||||
- Update gitlab_git gem to 10.4.7
|
||||
- Simplify SQL queries of marking a todo as done
|
||||
|
||||
v 8.10.6
|
||||
- Upgrade Rails to 4.2.7.1 for security fixes. !5781
|
||||
|
|
12
Gemfile
12
Gemfile
|
@ -20,7 +20,7 @@ gem 'pg', '~> 0.18.2', group: :postgres
|
|||
|
||||
# Authentication libraries
|
||||
gem 'devise', '~> 4.0'
|
||||
gem 'doorkeeper', '~> 4.0'
|
||||
gem 'doorkeeper', '~> 4.2.0'
|
||||
gem 'omniauth', '~> 1.3.1'
|
||||
gem 'omniauth-auth0', '~> 1.4.1'
|
||||
gem 'omniauth-azure-oauth2', '~> 0.0.6'
|
||||
|
@ -53,7 +53,7 @@ gem 'browser', '~> 2.2'
|
|||
|
||||
# Extracting information from a git repository
|
||||
# Provide access to Gitlab::Git library
|
||||
gem 'gitlab_git', '~> 10.4.5'
|
||||
gem 'gitlab_git', '~> 10.4.7'
|
||||
|
||||
# LDAP Auth
|
||||
# GitLab fork with several improvements to original library. For full list of changes
|
||||
|
@ -77,7 +77,7 @@ gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
|
|||
gem 'kaminari', '~> 0.17.0'
|
||||
|
||||
# HAML
|
||||
gem 'hamlit', '~> 2.5'
|
||||
gem 'hamlit', '~> 2.6.1'
|
||||
|
||||
# Files attachments
|
||||
gem 'carrierwave', '~> 0.10.0'
|
||||
|
@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0'
|
|||
gem 'rack-attack', '~> 4.3.1'
|
||||
|
||||
# Ace editor
|
||||
gem 'ace-rails-ap', '~> 4.0.2'
|
||||
gem 'ace-rails-ap', '~> 4.1.0'
|
||||
|
||||
# Keyboard shortcuts
|
||||
gem 'mousetrap-rails', '~> 1.4.6'
|
||||
|
@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
|
|||
# Detect and convert string character encoding
|
||||
gem 'charlock_holmes', '~> 0.7.3'
|
||||
|
||||
# Parse duration
|
||||
# Parse time & duration
|
||||
gem 'chronic', '~> 0.10.2'
|
||||
gem 'chronic_duration', '~> 0.10.6'
|
||||
|
||||
gem 'sass-rails', '~> 5.0.0'
|
||||
|
@ -314,6 +315,7 @@ end
|
|||
group :test do
|
||||
gem 'shoulda-matchers', '~> 2.8.0', require: false
|
||||
gem 'email_spec', '~> 1.6.0'
|
||||
gem 'json-schema', '~> 2.6.2'
|
||||
gem 'webmock', '~> 1.21.0'
|
||||
gem 'test_after_commit', '~> 0.4.2'
|
||||
gem 'sham_rack', '~> 1.3.6'
|
||||
|
|
21
Gemfile.lock
21
Gemfile.lock
|
@ -2,7 +2,7 @@ GEM
|
|||
remote: https://rubygems.org/
|
||||
specs:
|
||||
RedCloth (4.3.2)
|
||||
ace-rails-ap (4.0.2)
|
||||
ace-rails-ap (4.1.0)
|
||||
actionmailer (4.2.7.1)
|
||||
actionpack (= 4.2.7.1)
|
||||
actionview (= 4.2.7.1)
|
||||
|
@ -128,6 +128,7 @@ GEM
|
|||
mime-types (>= 1.16)
|
||||
cause (0.1)
|
||||
charlock_holmes (0.7.3)
|
||||
chronic (0.10.2)
|
||||
chronic_duration (0.10.6)
|
||||
numerizer (~> 0.1.1)
|
||||
chunky_png (1.3.5)
|
||||
|
@ -175,7 +176,7 @@ GEM
|
|||
diff-lcs (1.2.5)
|
||||
diffy (3.0.7)
|
||||
docile (1.1.5)
|
||||
doorkeeper (4.0.0)
|
||||
doorkeeper (4.2.0)
|
||||
railties (>= 4.2)
|
||||
dropzonejs-rails (0.7.2)
|
||||
rails (> 3.1)
|
||||
|
@ -278,7 +279,7 @@ GEM
|
|||
diff-lcs (~> 1.1)
|
||||
mime-types (>= 1.16, < 3)
|
||||
posix-spawn (~> 0.3)
|
||||
gitlab_git (10.4.5)
|
||||
gitlab_git (10.4.7)
|
||||
activesupport (~> 4.0)
|
||||
charlock_holmes (~> 0.7.3)
|
||||
github-linguist (~> 4.7.0)
|
||||
|
@ -321,7 +322,7 @@ GEM
|
|||
grape-entity (0.4.8)
|
||||
activesupport
|
||||
multi_json (>= 1.3.2)
|
||||
hamlit (2.5.0)
|
||||
hamlit (2.6.1)
|
||||
temple (~> 0.7.6)
|
||||
thor
|
||||
tilt
|
||||
|
@ -356,6 +357,8 @@ GEM
|
|||
jquery-ui-rails (5.0.5)
|
||||
railties (>= 3.2.16)
|
||||
json (1.8.3)
|
||||
json-schema (2.6.2)
|
||||
addressable (~> 2.3.8)
|
||||
jwt (1.5.4)
|
||||
kaminari (0.17.0)
|
||||
actionpack (>= 3.0.0)
|
||||
|
@ -796,7 +799,7 @@ PLATFORMS
|
|||
|
||||
DEPENDENCIES
|
||||
RedCloth (~> 4.3.2)
|
||||
ace-rails-ap (~> 4.0.2)
|
||||
ace-rails-ap (~> 4.1.0)
|
||||
activerecord-session_store (~> 1.0.0)
|
||||
acts-as-taggable-on (~> 3.4)
|
||||
addressable (~> 2.3.8)
|
||||
|
@ -822,6 +825,7 @@ DEPENDENCIES
|
|||
capybara-screenshot (~> 1.0.0)
|
||||
carrierwave (~> 0.10.0)
|
||||
charlock_holmes (~> 0.7.3)
|
||||
chronic (~> 0.10.2)
|
||||
chronic_duration (~> 0.10.6)
|
||||
coffee-rails (~> 4.1.0)
|
||||
connection_pool (~> 2.0)
|
||||
|
@ -832,7 +836,7 @@ DEPENDENCIES
|
|||
devise (~> 4.0)
|
||||
devise-two-factor (~> 3.0.0)
|
||||
diffy (~> 3.0.3)
|
||||
doorkeeper (~> 4.0)
|
||||
doorkeeper (~> 4.2.0)
|
||||
dropzonejs-rails (~> 0.7.1)
|
||||
email_reply_parser (~> 0.5.8)
|
||||
email_spec (~> 1.6.0)
|
||||
|
@ -855,7 +859,7 @@ DEPENDENCIES
|
|||
github-linguist (~> 4.7.0)
|
||||
github-markup (~> 1.4)
|
||||
gitlab-flowdock-git-hook (~> 1.0.1)
|
||||
gitlab_git (~> 10.4.5)
|
||||
gitlab_git (~> 10.4.7)
|
||||
gitlab_meta (= 7.0)
|
||||
gitlab_omniauth-ldap (~> 1.2.1)
|
||||
gollum-lib (~> 4.2)
|
||||
|
@ -863,7 +867,7 @@ DEPENDENCIES
|
|||
gon (~> 6.1.0)
|
||||
grape (~> 0.15.0)
|
||||
grape-entity (~> 0.4.2)
|
||||
hamlit (~> 2.5)
|
||||
hamlit (~> 2.6.1)
|
||||
health_check (~> 2.1.0)
|
||||
hipchat (~> 1.5.0)
|
||||
html-pipeline (~> 1.11.0)
|
||||
|
@ -873,6 +877,7 @@ DEPENDENCIES
|
|||
jquery-rails (~> 4.1.0)
|
||||
jquery-turbolinks (~> 2.1.0)
|
||||
jquery-ui-rails (~> 5.0.0)
|
||||
json-schema (~> 2.6.2)
|
||||
jwt
|
||||
kaminari (~> 0.17.0)
|
||||
knapsack (~> 1.11.0)
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
window.gl = window.gl || {};
|
||||
((global) => {
|
||||
const MAX_MESSAGE_LENGTH = 500;
|
||||
const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
|
||||
|
||||
class AbuseReports {
|
||||
constructor() {
|
||||
$(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
|
||||
$(document)
|
||||
.off('click', MESSAGE_CELL_SELECTOR)
|
||||
.on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
|
||||
}
|
||||
|
||||
truncateLongMessage() {
|
||||
const $messageCellElement = $(this);
|
||||
const reportMessage = $messageCellElement.text();
|
||||
if (reportMessage.length > MAX_MESSAGE_LENGTH) {
|
||||
$messageCellElement.data('original-message', reportMessage);
|
||||
$messageCellElement.data('message-truncated', 'true');
|
||||
$messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
toggleMessageTruncation() {
|
||||
const $messageCellElement = $(this);
|
||||
const originalMessage = $messageCellElement.data('original-message');
|
||||
if (!originalMessage) return;
|
||||
if ($messageCellElement.data('message-truncated') === 'true') {
|
||||
$messageCellElement.data('message-truncated', 'false');
|
||||
$messageCellElement.text(originalMessage);
|
||||
} else {
|
||||
$messageCellElement.data('message-truncated', 'true');
|
||||
$messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.AbuseReports = AbuseReports;
|
||||
})(window.gl);
|
|
@ -26,7 +26,7 @@
|
|||
/*= require bootstrap/tooltip */
|
||||
/*= require bootstrap/popover */
|
||||
/*= require select2 */
|
||||
/*= require ace/ace */
|
||||
/*= require ace-rails-ap */
|
||||
/*= require ace/ext-searchbox */
|
||||
/*= require underscore */
|
||||
/*= require dropzone */
|
||||
|
@ -224,8 +224,14 @@
|
|||
return $('.navbar-toggle').toggleClass('active');
|
||||
});
|
||||
$body.on("click", ".js-toggle-diff-comments", function(e) {
|
||||
$(this).toggleClass('active');
|
||||
$(this).closest(".diff-file").find(".notes_holder").toggle();
|
||||
var $this = $(this);
|
||||
$this.toggleClass('active');
|
||||
var notesHolders = $this.closest('.diff-file').find('.notes_holder');
|
||||
if ($this.hasClass('active')) {
|
||||
notesHolders.show();
|
||||
} else {
|
||||
notesHolders.hide();
|
||||
}
|
||||
return e.preventDefault();
|
||||
});
|
||||
$document.off("click", '.js-confirm-danger');
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
(function() {
|
||||
this.AwardsHandler = (function() {
|
||||
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence
|
||||
function AwardsHandler() {
|
||||
this.aliases = gl.emojiAliases();
|
||||
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
|
||||
|
@ -130,7 +131,7 @@
|
|||
counter = $emojiButton.find('.js-counter');
|
||||
counter.text(parseInt(counter.text()) + 1);
|
||||
$emojiButton.addClass('active');
|
||||
this.addMeToUserList(votesBlock, emoji);
|
||||
this.addYouToUserList(votesBlock, emoji);
|
||||
return this.animateEmoji($emojiButton);
|
||||
}
|
||||
} else {
|
||||
|
@ -176,11 +177,11 @@
|
|||
counterNumber = parseInt(counter.text(), 10);
|
||||
if (counterNumber > 1) {
|
||||
counter.text(counterNumber - 1);
|
||||
this.removeMeFromUserList($emojiButton, emoji);
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
|
||||
$emojiButton.tooltip('destroy');
|
||||
counter.text('0');
|
||||
this.removeMeFromUserList($emojiButton, emoji);
|
||||
this.removeYouFromUserList($emojiButton, emoji);
|
||||
if ($emojiButton.parents('.note').length) {
|
||||
this.removeEmoji($emojiButton);
|
||||
}
|
||||
|
@ -204,43 +205,48 @@
|
|||
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) {
|
||||
AwardsHandler.prototype.toSentence = function(list) {
|
||||
if(list.length <= 2){
|
||||
return list.join(' and ');
|
||||
}
|
||||
else{
|
||||
return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
|
||||
}
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
|
||||
var authors, awardBlock, newAuthors, originalTitle;
|
||||
awardBlock = $emojiButton;
|
||||
originalTitle = this.getAwardTooltip(awardBlock);
|
||||
authors = originalTitle.split(', ');
|
||||
authors.splice(authors.indexOf('me'), 1);
|
||||
newAuthors = authors.join(', ');
|
||||
awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors);
|
||||
return this.resetTooltip(awardBlock);
|
||||
authors = originalTitle.split(FROM_SENTENCE_REGEX);
|
||||
authors.splice(authors.indexOf('You'), 1);
|
||||
return awardBlock
|
||||
.closest('.js-emoji-btn')
|
||||
.removeData('title')
|
||||
.removeAttr('data-title')
|
||||
.removeAttr('data-original-title')
|
||||
.attr('title', this.toSentence(authors))
|
||||
.tooltip('fixTitle');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) {
|
||||
AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
|
||||
var awardBlock, origTitle, users;
|
||||
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
|
||||
origTitle = this.getAwardTooltip(awardBlock);
|
||||
users = [];
|
||||
if (origTitle) {
|
||||
users = origTitle.trim().split(', ');
|
||||
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
|
||||
}
|
||||
users.push('me');
|
||||
awardBlock.attr('title', users.join(', '));
|
||||
return this.resetTooltip(awardBlock);
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.resetTooltip = function(award) {
|
||||
var cb;
|
||||
award.tooltip('destroy');
|
||||
cb = function() {
|
||||
return award.tooltip();
|
||||
};
|
||||
return setTimeout(cb, 200);
|
||||
users.unshift('You');
|
||||
return awardBlock
|
||||
.attr('title', this.toSentence(users))
|
||||
.tooltip('fixTitle');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
|
||||
var $emojiButton, buttonHtml, emojiCssClass;
|
||||
emojiCssClass = this.resolveNameToCssClass(emoji);
|
||||
buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
|
||||
buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
|
||||
$emojiButton = $(buttonHtml);
|
||||
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
|
||||
this.animateEmoji($emojiButton);
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
||||
//= require Sortable
|
||||
//= require_tree ./models
|
||||
//= require_tree ./stores
|
||||
//= require_tree ./services
|
||||
//= require_tree ./mixins
|
||||
//= require ./components/board
|
||||
//= require ./components/new_list_dropdown
|
||||
//= require ./vue_resource_interceptor
|
||||
|
||||
$(() => {
|
||||
const $boardApp = document.getElementById('board-app'),
|
||||
Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
window.gl = window.gl || {};
|
||||
|
||||
if (gl.IssueBoardsApp) {
|
||||
gl.IssueBoardsApp.$destroy(true);
|
||||
}
|
||||
|
||||
gl.IssueBoardsApp = new Vue({
|
||||
el: $boardApp,
|
||||
components: {
|
||||
'board': gl.issueBoards.Board
|
||||
},
|
||||
data: {
|
||||
state: Store.state,
|
||||
loading: true,
|
||||
endpoint: $boardApp.dataset.endpoint,
|
||||
disabled: $boardApp.dataset.disabled === 'true',
|
||||
issueLinkBase: $boardApp.dataset.issueLinkBase
|
||||
},
|
||||
init: Store.create.bind(Store),
|
||||
created () {
|
||||
gl.boardService = new BoardService(this.endpoint);
|
||||
},
|
||||
ready () {
|
||||
Store.disabled = this.disabled;
|
||||
gl.boardService.all()
|
||||
.then((resp) => {
|
||||
resp.json().forEach((board) => {
|
||||
const list = Store.addList(board);
|
||||
|
||||
if (list.type === 'done') {
|
||||
list.position = Infinity;
|
||||
} else if (list.type === 'backlog') {
|
||||
list.position = -1;
|
||||
}
|
||||
});
|
||||
|
||||
Store.addBlankState();
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,85 @@
|
|||
//= require ./board_blank_state
|
||||
//= require ./board_delete
|
||||
//= require ./board_list
|
||||
|
||||
(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.Board = Vue.extend({
|
||||
components: {
|
||||
'board-list': gl.issueBoards.BoardList,
|
||||
'board-delete': gl.issueBoards.BoardDelete,
|
||||
'board-blank-state': gl.issueBoards.BoardBlankState
|
||||
},
|
||||
props: {
|
||||
list: Object,
|
||||
disabled: Boolean,
|
||||
issueLinkBase: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
query: '',
|
||||
filters: Store.state.filters
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.list.filters = this.getFilterData();
|
||||
this.list.getIssues(true);
|
||||
},
|
||||
filters: {
|
||||
handler () {
|
||||
this.list.page = 1;
|
||||
this.list.getIssues(true);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFilterData () {
|
||||
const filters = this.filters;
|
||||
let queryData = { search: this.query };
|
||||
|
||||
Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; });
|
||||
|
||||
return queryData;
|
||||
}
|
||||
},
|
||||
ready () {
|
||||
const options = gl.issueBoards.getBoardSortableDefaultOptions({
|
||||
disabled: this.disabled,
|
||||
group: 'boards',
|
||||
draggable: '.is-draggable',
|
||||
handle: '.js-board-handle',
|
||||
onEnd: (e) => {
|
||||
document.body.classList.remove('is-dragging');
|
||||
|
||||
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
|
||||
const order = this.sortable.toArray(),
|
||||
$board = this.$parent.$refs.board[e.oldIndex + 1],
|
||||
list = $board.list;
|
||||
|
||||
$board.$destroy(true);
|
||||
|
||||
this.$nextTick(() => {
|
||||
Store.state.lists.splice(e.newIndex, 0, list);
|
||||
Store.moveList(list, order);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (bp.getBreakpointSize() === 'xs') {
|
||||
options.handle = '.js-board-drag-handle';
|
||||
}
|
||||
|
||||
this.sortable = Sortable.create(this.$el.parentNode, options);
|
||||
},
|
||||
beforeDestroy () {
|
||||
Store.state.lists.$remove(this.list);
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,49 @@
|
|||
(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardBlankState = Vue.extend({
|
||||
data () {
|
||||
return {
|
||||
predefinedLabels: [
|
||||
new ListLabel({ title: 'Development', color: '#5CB85C' }),
|
||||
new ListLabel({ title: 'Testing', color: '#F0AD4E' }),
|
||||
new ListLabel({ title: 'Production', color: '#FF5F00' }),
|
||||
new ListLabel({ title: 'Ready', color: '#FF0000' })
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addDefaultLists () {
|
||||
this.clearBlankState();
|
||||
|
||||
this.predefinedLabels.forEach((label, i) => {
|
||||
Store.addList({
|
||||
title: label.title,
|
||||
position: i,
|
||||
list_type: 'label',
|
||||
label: {
|
||||
title: label.title,
|
||||
color: label.color
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save the labels
|
||||
gl.boardService.generateDefaultLists()
|
||||
.then((resp) => {
|
||||
resp.json().forEach((listObj) => {
|
||||
const list = Store.findList('title', listObj.title);
|
||||
|
||||
list.id = listObj.id;
|
||||
list.label.id = listObj.label.id;
|
||||
list.getIssues();
|
||||
});
|
||||
});
|
||||
},
|
||||
clearBlankState: Store.removeBlankState.bind(Store)
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,43 @@
|
|||
(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardCard = Vue.extend({
|
||||
props: {
|
||||
list: Object,
|
||||
issue: Object,
|
||||
issueLinkBase: String,
|
||||
disabled: Boolean,
|
||||
index: Number
|
||||
},
|
||||
methods: {
|
||||
filterByLabel (label, e) {
|
||||
let labelToggleText = label.title;
|
||||
const labelIndex = Store.state.filters['label_name'].indexOf(label.title);
|
||||
$(e.target).tooltip('hide');
|
||||
|
||||
if (labelIndex === -1) {
|
||||
Store.state.filters['label_name'].push(label.title);
|
||||
$('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`);
|
||||
} else {
|
||||
Store.state.filters['label_name'].splice(labelIndex, 1);
|
||||
labelToggleText = Store.state.filters['label_name'][0];
|
||||
$(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove();
|
||||
}
|
||||
|
||||
const selectedLabels = Store.state.filters['label_name'];
|
||||
if (selectedLabels.length === 0) {
|
||||
labelToggleText = 'Label';
|
||||
} else if (selectedLabels.length > 1) {
|
||||
labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`;
|
||||
}
|
||||
|
||||
$('.labels-filter .dropdown-toggle-text').text(labelToggleText);
|
||||
|
||||
Store.updateFiltersUrl();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,19 @@
|
|||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardDelete = Vue.extend({
|
||||
props: {
|
||||
list: Object
|
||||
},
|
||||
methods: {
|
||||
deleteBoard () {
|
||||
$(this.$el).tooltip('hide');
|
||||
|
||||
if (confirm('Are you sure you want to delete this list?')) {
|
||||
this.list.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,89 @@
|
|||
//= require ./board_card
|
||||
|
||||
(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardList = Vue.extend({
|
||||
components: {
|
||||
'board-card': gl.issueBoards.BoardCard
|
||||
},
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
list: Object,
|
||||
issues: Array,
|
||||
loading: Boolean,
|
||||
issueLinkBase: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
scrollOffset: 250,
|
||||
filters: Store.state.filters
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
handler () {
|
||||
this.list.loadingMore = false;
|
||||
this.$els.list.scrollTop = 0;
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
listHeight () {
|
||||
return this.$els.list.getBoundingClientRect().height;
|
||||
},
|
||||
scrollHeight () {
|
||||
return this.$els.list.scrollHeight;
|
||||
},
|
||||
scrollTop () {
|
||||
return this.$els.list.scrollTop + this.listHeight();
|
||||
},
|
||||
loadNextPage () {
|
||||
const getIssues = this.list.nextPage();
|
||||
|
||||
if (getIssues) {
|
||||
this.list.loadingMore = true;
|
||||
getIssues.then(() => {
|
||||
this.list.loadingMore = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
ready () {
|
||||
const options = gl.issueBoards.getBoardSortableDefaultOptions({
|
||||
group: 'issues',
|
||||
sort: false,
|
||||
disabled: this.disabled,
|
||||
onStart: (e) => {
|
||||
const card = this.$refs.issue[e.oldIndex];
|
||||
|
||||
Store.moving.issue = card.issue;
|
||||
Store.moving.list = card.list;
|
||||
},
|
||||
onAdd: (e) => {
|
||||
gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
|
||||
},
|
||||
onRemove: (e) => {
|
||||
this.$refs.issue[e.oldIndex].$destroy(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (bp.getBreakpointSize() === 'xs') {
|
||||
options.handle = '.js-card-drag-handle';
|
||||
}
|
||||
|
||||
this.sortable = Sortable.create(this.$els.list, options);
|
||||
|
||||
// Scroll event on list to load more
|
||||
this.$els.list.onscroll = () => {
|
||||
if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
|
||||
this.loadNextPage();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,54 @@
|
|||
$(() => {
|
||||
const Store = gl.issueBoards.BoardsStore;
|
||||
|
||||
$('.js-new-board-list').each(function () {
|
||||
const $this = $(this);
|
||||
|
||||
new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id'));
|
||||
|
||||
$this.glDropdown({
|
||||
data(term, callback) {
|
||||
$.get($this.attr('data-labels'))
|
||||
.then((resp) => {
|
||||
callback(resp);
|
||||
});
|
||||
},
|
||||
renderRow (label) {
|
||||
const active = Store.findList('title', label.title),
|
||||
$li = $('<li />'),
|
||||
$a = $('<a />', {
|
||||
class: (active ? `is-active js-board-list-${active.id}` : ''),
|
||||
text: label.title,
|
||||
href: '#'
|
||||
}),
|
||||
$labelColor = $('<span />', {
|
||||
class: 'dropdown-label-box',
|
||||
style: `background-color: ${label.color}`
|
||||
});
|
||||
|
||||
return $li.append($a.prepend($labelColor));
|
||||
},
|
||||
search: {
|
||||
fields: ['title']
|
||||
},
|
||||
filterable: true,
|
||||
selectable: true,
|
||||
clicked (label, $el, e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!Store.findList('title', label.title)) {
|
||||
Store.new({
|
||||
title: label.title,
|
||||
position: Store.state.lists.length - 2,
|
||||
list_type: 'label',
|
||||
label: {
|
||||
id: label.id,
|
||||
title: label.title,
|
||||
color: label.color
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
((w) => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
|
||||
let defaultSortOptions = {
|
||||
forceFallback: true,
|
||||
fallbackClass: 'is-dragging',
|
||||
fallbackOnBody: true,
|
||||
ghostClass: 'is-ghost',
|
||||
filter: '.has-tooltip',
|
||||
scrollSensitivity: 100,
|
||||
scrollSpeed: 20,
|
||||
onStart () {
|
||||
document.body.classList.add('is-dragging');
|
||||
},
|
||||
onEnd () {
|
||||
document.body.classList.remove('is-dragging');
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
|
||||
return defaultSortOptions;
|
||||
};
|
||||
})(window);
|
|
@ -0,0 +1,44 @@
|
|||
class ListIssue {
|
||||
constructor (obj) {
|
||||
this.id = obj.iid;
|
||||
this.title = obj.title;
|
||||
this.confidential = obj.confidential;
|
||||
this.labels = [];
|
||||
|
||||
if (obj.assignee) {
|
||||
this.assignee = new ListUser(obj.assignee);
|
||||
}
|
||||
|
||||
obj.labels.forEach((label) => {
|
||||
this.labels.push(new ListLabel(label));
|
||||
});
|
||||
|
||||
this.priority = this.labels.reduce((max, label) => {
|
||||
return (label.priority < max) ? label.priority : max;
|
||||
}, Infinity);
|
||||
}
|
||||
|
||||
addLabel (label) {
|
||||
if (!this.findLabel(label)) {
|
||||
this.labels.push(new ListLabel(label));
|
||||
}
|
||||
}
|
||||
|
||||
findLabel (findLabel) {
|
||||
return this.labels.filter( label => label.title === findLabel.title )[0];
|
||||
}
|
||||
|
||||
removeLabel (removeLabel) {
|
||||
if (removeLabel) {
|
||||
this.labels = this.labels.filter( label => removeLabel.title !== label.title );
|
||||
}
|
||||
}
|
||||
|
||||
removeLabels (labels) {
|
||||
labels.forEach(this.removeLabel.bind(this));
|
||||
}
|
||||
|
||||
getLists () {
|
||||
return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
class ListLabel {
|
||||
constructor (obj) {
|
||||
this.id = obj.id;
|
||||
this.title = obj.title;
|
||||
this.color = obj.color;
|
||||
this.description = obj.description;
|
||||
this.priority = (obj.priority !== null) ? obj.priority : Infinity;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
class List {
|
||||
constructor (obj) {
|
||||
this.id = obj.id;
|
||||
this._uid = this.guid();
|
||||
this.position = obj.position;
|
||||
this.title = obj.title;
|
||||
this.type = obj.list_type;
|
||||
this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1;
|
||||
this.filters = gl.issueBoards.BoardsStore.state.filters;
|
||||
this.page = 1;
|
||||
this.loading = true;
|
||||
this.loadingMore = false;
|
||||
this.issues = [];
|
||||
|
||||
if (obj.label) {
|
||||
this.label = new ListLabel(obj.label);
|
||||
}
|
||||
|
||||
if (this.type !== 'blank' && this.id) {
|
||||
this.getIssues();
|
||||
}
|
||||
}
|
||||
|
||||
guid() {
|
||||
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
|
||||
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
|
||||
}
|
||||
|
||||
save () {
|
||||
return gl.boardService.createList(this.label.id)
|
||||
.then((resp) => {
|
||||
const data = resp.json();
|
||||
|
||||
this.id = data.id;
|
||||
this.type = data.list_type;
|
||||
this.position = data.position;
|
||||
|
||||
return this.getIssues();
|
||||
});
|
||||
}
|
||||
|
||||
destroy () {
|
||||
gl.issueBoards.BoardsStore.state.lists.$remove(this);
|
||||
gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
|
||||
|
||||
gl.boardService.destroyList(this.id);
|
||||
}
|
||||
|
||||
update () {
|
||||
gl.boardService.updateList(this.id, this.position);
|
||||
}
|
||||
|
||||
nextPage () {
|
||||
if (Math.floor(this.issues.length / 20) === this.page) {
|
||||
this.page++;
|
||||
|
||||
return this.getIssues(false);
|
||||
}
|
||||
}
|
||||
|
||||
canSearch () {
|
||||
return this.type === 'backlog';
|
||||
}
|
||||
|
||||
getIssues (emptyIssues = true) {
|
||||
const filters = this.filters;
|
||||
let data = { page: this.page };
|
||||
|
||||
Object.keys(filters).forEach((key) => { data[key] = filters[key]; });
|
||||
|
||||
if (this.label) {
|
||||
data.label_name = data.label_name.filter( label => label !== this.label.title );
|
||||
}
|
||||
|
||||
if (emptyIssues) {
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
return gl.boardService.getIssuesForList(this.id, data)
|
||||
.then((resp) => {
|
||||
const data = resp.json();
|
||||
this.loading = false;
|
||||
|
||||
if (emptyIssues) {
|
||||
this.issues = [];
|
||||
}
|
||||
|
||||
this.createIssues(data);
|
||||
});
|
||||
}
|
||||
|
||||
createIssues (data) {
|
||||
data.forEach((issueObj) => {
|
||||
this.addIssue(new ListIssue(issueObj));
|
||||
});
|
||||
}
|
||||
|
||||
addIssue (issue, listFrom) {
|
||||
this.issues.push(issue);
|
||||
|
||||
if (this.label) {
|
||||
issue.addLabel(this.label);
|
||||
}
|
||||
|
||||
if (listFrom) {
|
||||
gl.boardService.moveIssue(issue.id, listFrom.id, this.id);
|
||||
}
|
||||
}
|
||||
|
||||
findIssue (id) {
|
||||
return this.issues.filter( issue => issue.id === id )[0];
|
||||
}
|
||||
|
||||
removeIssue (removeIssue) {
|
||||
this.issues = this.issues.filter((issue) => {
|
||||
const matchesRemove = removeIssue.id === issue.id;
|
||||
|
||||
if (matchesRemove) {
|
||||
issue.removeLabel(this.label);
|
||||
}
|
||||
|
||||
return !matchesRemove;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
class ListUser {
|
||||
constructor (user) {
|
||||
this.id = user.id;
|
||||
this.name = user.name;
|
||||
this.username = user.username;
|
||||
this.avatar = user.avatar_url;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
class BoardService {
|
||||
constructor (root) {
|
||||
Vue.http.options.root = root;
|
||||
|
||||
this.lists = Vue.resource(`${root}/lists{/id}`, {}, {
|
||||
generate: {
|
||||
method: 'POST',
|
||||
url: `${root}/lists/generate.json`
|
||||
}
|
||||
});
|
||||
this.issue = Vue.resource(`${root}/issues{/id}`, {});
|
||||
this.issues = Vue.resource(`${root}/lists{/id}/issues`, {});
|
||||
|
||||
Vue.http.interceptors.push((request, next) => {
|
||||
request.headers['X-CSRF-Token'] = $.rails.csrfToken();
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
all () {
|
||||
return this.lists.get();
|
||||
}
|
||||
|
||||
generateDefaultLists () {
|
||||
return this.lists.generate({});
|
||||
}
|
||||
|
||||
createList (label_id) {
|
||||
return this.lists.save({}, {
|
||||
list: {
|
||||
label_id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateList (id, position) {
|
||||
return this.lists.update({ id }, {
|
||||
list: {
|
||||
position
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroyList (id) {
|
||||
return this.lists.delete({ id });
|
||||
}
|
||||
|
||||
getIssuesForList (id, filter = {}) {
|
||||
let data = { id };
|
||||
Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
|
||||
|
||||
return this.issues.get(data);
|
||||
}
|
||||
|
||||
moveIssue (id, from_list_id, to_list_id) {
|
||||
return this.issue.update({ id }, {
|
||||
from_list_id,
|
||||
to_list_id
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,112 @@
|
|||
(() => {
|
||||
window.gl = window.gl || {};
|
||||
window.gl.issueBoards = window.gl.issueBoards || {};
|
||||
|
||||
gl.issueBoards.BoardsStore = {
|
||||
disabled: false,
|
||||
state: {},
|
||||
moving: {
|
||||
issue: {},
|
||||
list: {}
|
||||
},
|
||||
create () {
|
||||
this.state.lists = [];
|
||||
this.state.filters = {
|
||||
author_id: gl.utils.getParameterValues('author_id')[0],
|
||||
assignee_id: gl.utils.getParameterValues('assignee_id')[0],
|
||||
milestone_title: gl.utils.getParameterValues('milestone_title')[0],
|
||||
label_name: gl.utils.getParameterValues('label_name[]')
|
||||
};
|
||||
},
|
||||
addList (listObj) {
|
||||
const list = new List(listObj);
|
||||
this.state.lists.push(list);
|
||||
|
||||
return list;
|
||||
},
|
||||
new (listObj) {
|
||||
const list = this.addList(listObj),
|
||||
backlogList = this.findList('type', 'backlog', 'backlog');
|
||||
|
||||
list
|
||||
.save()
|
||||
.then(() => {
|
||||
// Remove any new issues from the backlog
|
||||
// as they will be visible in the new list
|
||||
list.issues.forEach(backlogList.removeIssue.bind(backlogList));
|
||||
});
|
||||
this.removeBlankState();
|
||||
},
|
||||
updateNewListDropdown (listId) {
|
||||
$(`.js-board-list-${listId}`).removeClass('is-active');
|
||||
},
|
||||
shouldAddBlankState () {
|
||||
// Decide whether to add the blank state
|
||||
return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]);
|
||||
},
|
||||
addBlankState () {
|
||||
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
|
||||
|
||||
this.addList({
|
||||
id: 'blank',
|
||||
list_type: 'blank',
|
||||
title: 'Welcome to your Issue Board!',
|
||||
position: 0
|
||||
});
|
||||
},
|
||||
removeBlankState () {
|
||||
this.removeList('blank');
|
||||
|
||||
$.cookie('issue_board_welcome_hidden', 'true', {
|
||||
expires: 365 * 10
|
||||
});
|
||||
},
|
||||
welcomeIsHidden () {
|
||||
return $.cookie('issue_board_welcome_hidden') === 'true';
|
||||
},
|
||||
removeList (id, type = 'blank') {
|
||||
const list = this.findList('id', id, type);
|
||||
|
||||
if (!list) return;
|
||||
|
||||
this.state.lists = this.state.lists.filter( list => list.id !== id );
|
||||
},
|
||||
moveList (listFrom, orderLists) {
|
||||
orderLists.forEach((id, i) => {
|
||||
const list = this.findList('id', parseInt(id));
|
||||
|
||||
list.position = i;
|
||||
});
|
||||
listFrom.update();
|
||||
},
|
||||
moveIssueToList (listFrom, listTo, issue) {
|
||||
const issueTo = listTo.findIssue(issue.id),
|
||||
issueLists = issue.getLists(),
|
||||
listLabels = issueLists.map( listIssue => listIssue.label );
|
||||
|
||||
// Add to new lists issues if it doesn't already exist
|
||||
if (!issueTo) {
|
||||
listTo.addIssue(issue, listFrom);
|
||||
}
|
||||
|
||||
if (listTo.type === 'done' && listFrom.type !== 'backlog') {
|
||||
issueLists.forEach((list) => {
|
||||
list.removeIssue(issue);
|
||||
})
|
||||
issue.removeLabels(listLabels);
|
||||
} else {
|
||||
listFrom.removeIssue(issue);
|
||||
}
|
||||
},
|
||||
findList (key, val, type = 'label') {
|
||||
return this.state.lists.filter((list) => {
|
||||
const byType = type ? list['type'] === type : true;
|
||||
|
||||
return list[key] === val && byType;
|
||||
})[0];
|
||||
},
|
||||
updateFiltersUrl () {
|
||||
history.pushState(null, null, `?${$.param(this.state.filters)}`);
|
||||
}
|
||||
};
|
||||
})();
|
|
@ -0,0 +1,119 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
function simulateEvent(el, type, options) {
|
||||
var event;
|
||||
if (!el) return;
|
||||
var ownerDocument = el.ownerDocument;
|
||||
|
||||
options = options || {};
|
||||
|
||||
if (/^mouse/.test(type)) {
|
||||
event = ownerDocument.createEvent('MouseEvents');
|
||||
event.initMouseEvent(type, true, true, ownerDocument.defaultView,
|
||||
options.button, options.screenX, options.screenY, options.clientX, options.clientY,
|
||||
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
|
||||
} else {
|
||||
event = ownerDocument.createEvent('CustomEvent');
|
||||
|
||||
event.initCustomEvent(type, true, true, ownerDocument.defaultView,
|
||||
options.button, options.screenX, options.screenY, options.clientX, options.clientY,
|
||||
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
|
||||
|
||||
event.dataTransfer = {
|
||||
data: {},
|
||||
|
||||
setData: function (type, val) {
|
||||
this.data[type] = val;
|
||||
},
|
||||
|
||||
getData: function (type) {
|
||||
return this.data[type];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (el.dispatchEvent) {
|
||||
el.dispatchEvent(event);
|
||||
} else if (el.fireEvent) {
|
||||
el.fireEvent('on' + type, event);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function getTraget(target) {
|
||||
var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
|
||||
var children = el.children;
|
||||
|
||||
return (
|
||||
children[target.index] ||
|
||||
children[target.index === 'first' ? 0 : -1] ||
|
||||
children[target.index === 'last' ? children.length - 1 : -1]
|
||||
);
|
||||
}
|
||||
|
||||
function getRect(el) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
var width = rect.right - rect.left;
|
||||
var height = rect.bottom - rect.top;
|
||||
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
cx: rect.left + width / 2,
|
||||
cy: rect.top + height / 2,
|
||||
w: width,
|
||||
h: height,
|
||||
hw: width / 2,
|
||||
wh: height / 2
|
||||
};
|
||||
}
|
||||
|
||||
function simulateDrag(options, callback) {
|
||||
options.to.el = options.to.el || options.from.el;
|
||||
|
||||
var fromEl = getTraget(options.from);
|
||||
var toEl = getTraget(options.to);
|
||||
var scrollable = options.scrollable;
|
||||
|
||||
var fromRect = getRect(fromEl);
|
||||
var toRect = getRect(toEl);
|
||||
|
||||
var startTime = new Date().getTime();
|
||||
var duration = options.duration || 1000;
|
||||
simulateEvent(fromEl, 'mousedown', {button: 0});
|
||||
options.ontap && options.ontap();
|
||||
window.SIMULATE_DRAG_ACTIVE = 1;
|
||||
|
||||
var dragInterval = setInterval(function loop() {
|
||||
var progress = (new Date().getTime() - startTime) / duration;
|
||||
var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
|
||||
var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop;
|
||||
var overEl = fromEl.ownerDocument.elementFromPoint(x, y);
|
||||
|
||||
simulateEvent(overEl, 'mousemove', {
|
||||
clientX: x,
|
||||
clientY: y
|
||||
});
|
||||
|
||||
if (progress >= 1) {
|
||||
options.ondragend && options.ondragend();
|
||||
simulateEvent(toEl, 'mouseup');
|
||||
clearInterval(dragInterval);
|
||||
window.SIMULATE_DRAG_ACTIVE = 0;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return {
|
||||
target: fromEl,
|
||||
fromList: fromEl.parentNode,
|
||||
toList: toEl.parentNode
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Export
|
||||
window.simulateEvent = simulateEvent;
|
||||
window.simulateDrag = simulateDrag;
|
||||
})();
|
|
@ -0,0 +1,10 @@
|
|||
Vue.http.interceptors.push((request, next) => {
|
||||
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
setTimeout(() => {
|
||||
Vue.activeResources--;
|
||||
}, 500);
|
||||
});
|
||||
next();
|
||||
});
|
|
@ -6,19 +6,26 @@
|
|||
|
||||
Build.state = null;
|
||||
|
||||
function Build(page_url, build_url, build_status, state1) {
|
||||
this.page_url = page_url;
|
||||
this.build_url = build_url;
|
||||
this.build_status = build_status;
|
||||
this.state = state1;
|
||||
function Build(options) {
|
||||
this.page_url = options.page_url;
|
||||
this.build_url = options.build_url;
|
||||
this.build_status = options.build_status;
|
||||
this.state = options.state1;
|
||||
this.build_stage = options.build_stage;
|
||||
this.hideSidebar = bind(this.hideSidebar, this);
|
||||
this.toggleSidebar = bind(this.toggleSidebar, this);
|
||||
this.updateDropdown = bind(this.updateDropdown, this);
|
||||
clearInterval(Build.interval);
|
||||
this.bp = Breakpoints.get();
|
||||
this.hideSidebar();
|
||||
$('.js-build-sidebar').niceScroll();
|
||||
|
||||
this.populateJobs(this.build_stage);
|
||||
this.updateStageDropdownText(this.build_stage);
|
||||
this.hideSidebar();
|
||||
|
||||
$(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
|
||||
$(window).off('resize.build').on('resize.build', this.hideSidebar);
|
||||
$(document).off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown);
|
||||
this.updateArtifactRemoveDate();
|
||||
if ($('#build-trace').length) {
|
||||
this.getInitialBuildTrace();
|
||||
|
@ -132,6 +139,22 @@
|
|||
}
|
||||
};
|
||||
|
||||
Build.prototype.populateJobs = function(stage) {
|
||||
$('.build-job').hide();
|
||||
$('.build-job[data-stage="' + stage + '"]').show();
|
||||
};
|
||||
|
||||
Build.prototype.updateStageDropdownText = function(stage) {
|
||||
$('.stage-selection').text(stage);
|
||||
};
|
||||
|
||||
Build.prototype.updateDropdown = function(e) {
|
||||
e.preventDefault();
|
||||
var stage = e.currentTarget.text;
|
||||
this.updateStageDropdownText(stage);
|
||||
this.populateJobs(stage);
|
||||
};
|
||||
|
||||
return Build;
|
||||
|
||||
})();
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
$(function() {
|
||||
var clipboard;
|
||||
|
||||
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
|
||||
clipboard.on('success', genericSuccess);
|
||||
return clipboard.on('error', genericError);
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
(function (w) {
|
||||
class CreateLabelDropdown {
|
||||
constructor ($el, projectId) {
|
||||
this.$el = $el;
|
||||
this.projectId = projectId;
|
||||
this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
|
||||
this.$cancelButton = $('.js-cancel-label-btn', this.$el);
|
||||
this.$newLabelField = $('#new_label_name', this.$el);
|
||||
this.$newColorField = $('#new_label_color', this.$el);
|
||||
this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
|
||||
this.$newLabelError = $('.js-label-error', this.$el);
|
||||
this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
|
||||
this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
|
||||
|
||||
this.$newLabelError.hide();
|
||||
this.$newLabelCreateButton.disable();
|
||||
|
||||
this.cleanBinding();
|
||||
this.addBinding();
|
||||
}
|
||||
|
||||
cleanBinding () {
|
||||
this.$colorSuggestions.off('click');
|
||||
this.$newLabelField.off('keyup change');
|
||||
this.$newColorField.off('keyup change');
|
||||
this.$dropdownBack.off('click');
|
||||
this.$cancelButton.off('click');
|
||||
this.$newLabelCreateButton.off('click');
|
||||
}
|
||||
|
||||
addBinding () {
|
||||
const self = this;
|
||||
|
||||
this.$colorSuggestions.on('click', function (e) {
|
||||
const $this = $(this);
|
||||
self.addColorValue(e, $this);
|
||||
});
|
||||
|
||||
this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
|
||||
this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
|
||||
|
||||
this.$dropdownBack.on('click', this.resetForm.bind(this));
|
||||
|
||||
this.$cancelButton.on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
self.resetForm();
|
||||
self.$dropdownBack.trigger('click');
|
||||
});
|
||||
|
||||
this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
|
||||
}
|
||||
|
||||
addColorValue (e, $this) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.$newColorField.val($this.data('color')).trigger('change');
|
||||
this.$colorPreview
|
||||
.css('background-color', $this.data('color'))
|
||||
.parent()
|
||||
.addClass('is-active');
|
||||
}
|
||||
|
||||
enableLabelCreateButton () {
|
||||
if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
|
||||
this.$newLabelError.hide();
|
||||
this.$newLabelCreateButton.enable();
|
||||
} else {
|
||||
this.$newLabelCreateButton.disable();
|
||||
}
|
||||
}
|
||||
|
||||
resetForm () {
|
||||
this.$newLabelField
|
||||
.val('')
|
||||
.trigger('change');
|
||||
|
||||
this.$newColorField
|
||||
.val('')
|
||||
.trigger('change');
|
||||
|
||||
this.$colorPreview
|
||||
.css('background-color', '')
|
||||
.parent()
|
||||
.removeClass('is-active');
|
||||
}
|
||||
|
||||
saveLabel (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
Api.newLabel(this.projectId, {
|
||||
name: this.$newLabelField.val(),
|
||||
color: this.$newColorField.val()
|
||||
}, (label) => {
|
||||
this.$newLabelCreateButton.enable();
|
||||
|
||||
if (label.message) {
|
||||
let errors;
|
||||
|
||||
if (typeof label.message === 'string') {
|
||||
errors = label.message;
|
||||
} else {
|
||||
errors = label.message.map(function (value, key) {
|
||||
return key + " " + value[0];
|
||||
}).join("<br/>");
|
||||
}
|
||||
|
||||
this.$newLabelError
|
||||
.html(errors)
|
||||
.show();
|
||||
} else {
|
||||
this.$dropdownBack.trigger('click');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!w.gl) {
|
||||
w.gl = {};
|
||||
}
|
||||
|
||||
gl.CreateLabelDropdown = CreateLabelDropdown;
|
||||
})(window);
|
|
@ -0,0 +1,49 @@
|
|||
((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);
|
|
@ -0,0 +1,188 @@
|
|||
(() => {
|
||||
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);
|
||||
})();
|
|
@ -0,0 +1,107 @@
|
|||
((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);
|
|
@ -0,0 +1,18 @@
|
|||
((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);
|
|
@ -0,0 +1,60 @@
|
|||
((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);
|
|
@ -0,0 +1,35 @@
|
|||
//= 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
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
((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);
|
|
@ -0,0 +1,9 @@
|
|||
((w) => {
|
||||
w.ButtonMixins = {
|
||||
computed: {
|
||||
namespace: function () {
|
||||
return `${this.namespacePath}/${this.projectPath}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
})(window);
|
|
@ -0,0 +1,87 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
((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);
|
|
@ -0,0 +1,53 @@
|
|||
((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);
|
|
@ -88,6 +88,8 @@
|
|||
new ZenMode();
|
||||
new MergedButtons();
|
||||
break;
|
||||
case "projects:merge_requests:conflicts":
|
||||
window.mcui = new MergeConflictResolver()
|
||||
case 'projects:merge_requests:index':
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
Issuable.init();
|
||||
|
@ -194,6 +196,9 @@
|
|||
case 'edit':
|
||||
new Labels();
|
||||
}
|
||||
case 'abuse_reports':
|
||||
new gl.AbuseReports();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'dashboard':
|
||||
|
|
|
@ -223,7 +223,7 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
return this.input.atwho({
|
||||
this.input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
searchKey: 'search',
|
||||
|
@ -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() {
|
||||
return this.input.atwho('destroy');
|
||||
|
@ -265,6 +327,7 @@
|
|||
this.input.atwho('load', 'mergerequests', data.mergerequests);
|
||||
this.input.atwho('load', ':', data.emojis);
|
||||
this.input.atwho('load', '~', data.labels);
|
||||
this.input.atwho('load', '/', data.commands);
|
||||
return $(':focus').trigger('keyup');
|
||||
}
|
||||
};
|
|
@ -4,7 +4,7 @@
|
|||
var _this;
|
||||
_this = this;
|
||||
$('.js-label-select').each(function(i, dropdown) {
|
||||
var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo;
|
||||
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo;
|
||||
$dropdown = $(dropdown);
|
||||
projectId = $dropdown.data('project-id');
|
||||
labelUrl = $dropdown.data('labels');
|
||||
|
@ -13,8 +13,6 @@
|
|||
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
|
||||
selectedLabel = selectedLabel.split(',');
|
||||
}
|
||||
newLabelField = $('#new_label_name');
|
||||
newColorField = $('#new_label_color');
|
||||
showNo = $dropdown.data('show-no');
|
||||
showAny = $dropdown.data('show-any');
|
||||
defaultLabel = $dropdown.data('default-label');
|
||||
|
@ -24,10 +22,6 @@
|
|||
$form = $dropdown.closest('form');
|
||||
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
|
||||
$value = $block.find('.value');
|
||||
$newLabelError = $('.js-label-error');
|
||||
$colorPreview = $('.js-dropdown-label-color-preview');
|
||||
$newLabelCreateButton = $('.js-new-label-btn');
|
||||
$newLabelError.hide();
|
||||
$loading = $block.find('.block-loading').fadeOut();
|
||||
if (issueUpdateURL != null) {
|
||||
issueURLSplit = issueUpdateURL.split('/');
|
||||
|
@ -36,62 +30,9 @@
|
|||
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
|
||||
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
|
||||
}
|
||||
if (newLabelField.length) {
|
||||
$('.suggest-colors-dropdown a').on("click", function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
newColorField.val($(this).data('color')).trigger('change');
|
||||
return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active');
|
||||
});
|
||||
resetForm = function() {
|
||||
newLabelField.val('').trigger('change');
|
||||
newColorField.val('').trigger('change');
|
||||
return $colorPreview.css('background-color', '').parent().removeClass('is-active');
|
||||
};
|
||||
$('.dropdown-menu-back').on('click', function() {
|
||||
return resetForm();
|
||||
});
|
||||
$('.js-cancel-label-btn').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetForm();
|
||||
return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
|
||||
});
|
||||
enableLabelCreateButton = function() {
|
||||
if (newLabelField.val() !== '' && newColorField.val() !== '') {
|
||||
$newLabelError.hide();
|
||||
return $newLabelCreateButton.enable();
|
||||
} else {
|
||||
return $newLabelCreateButton.disable();
|
||||
}
|
||||
};
|
||||
saveLabel = function() {
|
||||
return Api.newLabel(projectId, {
|
||||
name: newLabelField.val(),
|
||||
color: newColorField.val()
|
||||
}, function(label) {
|
||||
$newLabelCreateButton.enable();
|
||||
if (label.message != null) {
|
||||
var errorText = label.message;
|
||||
if (_.isObject(label.message)) {
|
||||
errorText = _.map(label.message, function(value, key) {
|
||||
return key + " " + value[0];
|
||||
}).join('<br/>');
|
||||
}
|
||||
return $newLabelError.html(errorText).show();
|
||||
} else {
|
||||
return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
|
||||
}
|
||||
});
|
||||
};
|
||||
newLabelField.on('keyup change', enableLabelCreateButton);
|
||||
newColorField.on('keyup change', enableLabelCreateButton);
|
||||
$newLabelCreateButton.disable().on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return saveLabel();
|
||||
});
|
||||
}
|
||||
|
||||
new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
|
||||
|
||||
saveLabelData = function() {
|
||||
var data, selected;
|
||||
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
|
||||
|
@ -272,6 +213,9 @@
|
|||
isMRIndex = page === 'projects:merge_requests:index';
|
||||
$selectbox.hide();
|
||||
$value.removeAttr('style');
|
||||
if (page === 'projects:boards:show') {
|
||||
return;
|
||||
}
|
||||
if ($dropdown.hasClass('js-multiselect')) {
|
||||
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
|
||||
|
@ -291,7 +235,7 @@
|
|||
}
|
||||
},
|
||||
multiSelect: $dropdown.hasClass('js-multiselect'),
|
||||
clicked: function(label) {
|
||||
clicked: function(label, $el, e) {
|
||||
var isIssueIndex, isMRIndex, page;
|
||||
_this.enableBulkLabelDropdown();
|
||||
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
||||
|
@ -300,7 +244,23 @@
|
|||
page = $('body').data('page');
|
||||
isIssueIndex = page === 'projects:issues:index';
|
||||
isMRIndex = page === 'projects:merge_requests:index';
|
||||
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
if (page === 'projects:boards:show') {
|
||||
if (label.isAny) {
|
||||
gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
|
||||
} else if (label.title) {
|
||||
gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
|
||||
} else {
|
||||
var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
|
||||
filters = filters.filter(function (label) {
|
||||
return label !== $el.text().trim();
|
||||
});
|
||||
gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
|
||||
}
|
||||
|
||||
gl.issueBoards.BoardsStore.updateFiltersUrl();
|
||||
e.preventDefault();
|
||||
return;
|
||||
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
if (!$dropdown.hasClass('js-multiselect')) {
|
||||
selectedLabel = label.title;
|
||||
return Issuable.filterResults($dropdown.closest('form'));
|
||||
|
|
|
@ -104,9 +104,12 @@
|
|||
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
|
||||
});
|
||||
};
|
||||
return gl.text.removeListeners = function(form) {
|
||||
gl.text.removeListeners = function(form) {
|
||||
return $('.js-md', form).off();
|
||||
};
|
||||
return gl.text.truncate = function(string, maxLength) {
|
||||
return string.substr(0, (maxLength - 3)) + '...';
|
||||
};
|
||||
})(window);
|
||||
|
||||
}).call(this);
|
||||
|
|
|
@ -0,0 +1,341 @@
|
|||
const HEAD_HEADER_TEXT = 'HEAD//our changes';
|
||||
const ORIGIN_HEADER_TEXT = 'origin//their changes';
|
||||
const HEAD_BUTTON_TITLE = 'Use ours';
|
||||
const ORIGIN_BUTTON_TITLE = 'Use theirs';
|
||||
|
||||
|
||||
class MergeConflictDataProvider {
|
||||
|
||||
getInitialData() {
|
||||
const diffViewType = $.cookie('diff_view');
|
||||
|
||||
return {
|
||||
isLoading : true,
|
||||
hasError : false,
|
||||
isParallel : diffViewType === 'parallel',
|
||||
diffViewType : diffViewType,
|
||||
isSubmitting : false,
|
||||
conflictsData : {},
|
||||
resolutionData : {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
decorateData(vueInstance, data) {
|
||||
this.vueInstance = vueInstance;
|
||||
|
||||
if (data.type === 'error') {
|
||||
vueInstance.hasError = true;
|
||||
data.errorMessage = data.message;
|
||||
}
|
||||
else {
|
||||
data.shortCommitSha = data.commit_sha.slice(0, 7);
|
||||
data.commitMessage = data.commit_message;
|
||||
|
||||
this.setParallelLines(data);
|
||||
this.setInlineLines(data);
|
||||
this.updateResolutionsData(data);
|
||||
}
|
||||
|
||||
vueInstance.conflictsData = data;
|
||||
vueInstance.isSubmitting = false;
|
||||
|
||||
const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict';
|
||||
vueInstance.conflictsData.conflictsText = conflictsText;
|
||||
}
|
||||
|
||||
|
||||
updateResolutionsData(data) {
|
||||
const vi = this.vueInstance;
|
||||
|
||||
data.files.forEach( (file) => {
|
||||
file.sections.forEach( (section) => {
|
||||
if (section.conflict) {
|
||||
vi.$set(`resolutionData['${section.id}']`, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setParallelLines(data) {
|
||||
data.files.forEach( (file) => {
|
||||
file.filePath = this.getFilePath(file);
|
||||
file.iconClass = `fa-${file.blob_icon}`;
|
||||
file.blobPath = file.blob_path;
|
||||
file.parallelLines = [];
|
||||
const linesObj = { left: [], right: [] };
|
||||
|
||||
file.sections.forEach( (section) => {
|
||||
const { conflict, lines, id } = section;
|
||||
|
||||
if (conflict) {
|
||||
linesObj.left.push(this.getOriginHeaderLine(id));
|
||||
linesObj.right.push(this.getHeadHeaderLine(id));
|
||||
}
|
||||
|
||||
lines.forEach( (line) => {
|
||||
const { type } = line;
|
||||
|
||||
if (conflict) {
|
||||
if (type === 'old') {
|
||||
linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
|
||||
}
|
||||
else if (type === 'new') {
|
||||
linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
|
||||
}
|
||||
}
|
||||
else {
|
||||
const lineType = type || 'context';
|
||||
|
||||
linesObj.left.push (this.getLineForParallelView(line, id, lineType));
|
||||
linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
|
||||
}
|
||||
});
|
||||
|
||||
this.checkLineLengths(linesObj);
|
||||
});
|
||||
|
||||
for (let i = 0, len = linesObj.left.length; i < len; i++) {
|
||||
file.parallelLines.push([
|
||||
linesObj.right[i],
|
||||
linesObj.left[i]
|
||||
]);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
checkLineLengths(linesObj) {
|
||||
let { left, right } = linesObj;
|
||||
|
||||
if (left.length !== right.length) {
|
||||
if (left.length > right.length) {
|
||||
const diff = left.length - right.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
right.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
}
|
||||
else {
|
||||
const diff = right.length - left.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
left.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setInlineLines(data) {
|
||||
data.files.forEach( (file) => {
|
||||
file.iconClass = `fa-${file.blob_icon}`;
|
||||
file.blobPath = file.blob_path;
|
||||
file.filePath = this.getFilePath(file);
|
||||
file.inlineLines = []
|
||||
|
||||
file.sections.forEach( (section) => {
|
||||
let currentLineType = 'new';
|
||||
const { conflict, lines, id } = section;
|
||||
|
||||
if (conflict) {
|
||||
file.inlineLines.push(this.getHeadHeaderLine(id));
|
||||
}
|
||||
|
||||
lines.forEach( (line) => {
|
||||
const { type } = line;
|
||||
|
||||
if ((type === 'new' || type === 'old') && currentLineType !== type) {
|
||||
currentLineType = type;
|
||||
file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
|
||||
this.decorateLineForInlineView(line, id, conflict);
|
||||
file.inlineLines.push(line);
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
file.inlineLines.push(this.getOriginHeaderLine(id));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
handleSelected(sectionId, selection) {
|
||||
const vi = this.vueInstance;
|
||||
|
||||
vi.resolutionData[sectionId] = selection;
|
||||
vi.conflictsData.files.forEach( (file) => {
|
||||
file.inlineLines.forEach( (line) => {
|
||||
if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
|
||||
this.markLine(line, selection);
|
||||
}
|
||||
});
|
||||
|
||||
file.parallelLines.forEach( (lines) => {
|
||||
const left = lines[0];
|
||||
const right = lines[1];
|
||||
const hasSameId = right.id === sectionId || left.id === sectionId;
|
||||
const isLeftMatch = left.hasConflict || left.isHeader;
|
||||
const isRightMatch = right.hasConflict || right.isHeader;
|
||||
|
||||
if (hasSameId && (isLeftMatch || isRightMatch)) {
|
||||
this.markLine(left, selection);
|
||||
this.markLine(right, selection);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
updateViewType(newType) {
|
||||
const vi = this.vueInstance;
|
||||
|
||||
if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) {
|
||||
return;
|
||||
}
|
||||
|
||||
vi.diffView = newType;
|
||||
vi.isParallel = newType === 'parallel';
|
||||
$.cookie('diff_view', newType); // TODO: Make sure that cookie path added.
|
||||
$('.content-wrapper .container-fluid').toggleClass('container-limited');
|
||||
}
|
||||
|
||||
|
||||
markLine(line, selection) {
|
||||
if (selection === 'head' && line.isHead) {
|
||||
line.isSelected = true;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
else if (selection === 'origin' && line.isOrigin) {
|
||||
line.isSelected = true;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
else {
|
||||
line.isSelected = false;
|
||||
line.isUnselected = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getConflictsCount() {
|
||||
return Object.keys(this.vueInstance.resolutionData).length;
|
||||
}
|
||||
|
||||
|
||||
getResolvedCount() {
|
||||
let count = 0;
|
||||
const data = this.vueInstance.resolutionData;
|
||||
|
||||
for (const id in data) {
|
||||
const resolution = data[id];
|
||||
if (resolution) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
isReadyToCommit() {
|
||||
const { conflictsData, isSubmitting } = this.vueInstance
|
||||
const allResolved = this.getConflictsCount() === this.getResolvedCount();
|
||||
const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
|
||||
|
||||
return !isSubmitting && hasCommitMessage && allResolved;
|
||||
}
|
||||
|
||||
|
||||
getCommitButtonText() {
|
||||
const initial = 'Commit conflict resolution';
|
||||
const inProgress = 'Committing...';
|
||||
const vue = this.vueInstance;
|
||||
|
||||
return vue ? vue.isSubmitting ? inProgress : initial : initial;
|
||||
}
|
||||
|
||||
|
||||
decorateLineForInlineView(line, id, conflict) {
|
||||
const { type } = line;
|
||||
line.id = id;
|
||||
line.hasConflict = conflict;
|
||||
line.isHead = type === 'new';
|
||||
line.isOrigin = type === 'old';
|
||||
line.hasMatch = type === 'match';
|
||||
line.richText = line.rich_text;
|
||||
line.isSelected = false;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
|
||||
getLineForParallelView(line, id, lineType, isHead) {
|
||||
const { old_line, new_line, rich_text } = line;
|
||||
const hasConflict = lineType === 'conflict';
|
||||
|
||||
return {
|
||||
id,
|
||||
lineType,
|
||||
hasConflict,
|
||||
isHead : hasConflict && isHead,
|
||||
isOrigin : hasConflict && !isHead,
|
||||
hasMatch : lineType === 'match',
|
||||
lineNumber : isHead ? new_line : old_line,
|
||||
section : isHead ? 'head' : 'origin',
|
||||
richText : rich_text,
|
||||
isSelected : false,
|
||||
isUnselected : false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getHeadHeaderLine(id) {
|
||||
return {
|
||||
id : id,
|
||||
richText : HEAD_HEADER_TEXT,
|
||||
buttonTitle : HEAD_BUTTON_TITLE,
|
||||
type : 'new',
|
||||
section : 'head',
|
||||
isHeader : true,
|
||||
isHead : true,
|
||||
isSelected : false,
|
||||
isUnselected: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getOriginHeaderLine(id) {
|
||||
return {
|
||||
id : id,
|
||||
richText : ORIGIN_HEADER_TEXT,
|
||||
buttonTitle : ORIGIN_BUTTON_TITLE,
|
||||
type : 'old',
|
||||
section : 'origin',
|
||||
isHeader : true,
|
||||
isOrigin : true,
|
||||
isSelected : false,
|
||||
isUnselected: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleFailedRequest(vueInstance, data) {
|
||||
vueInstance.hasError = true;
|
||||
vueInstance.conflictsData.errorMessage = 'Something went wrong!';
|
||||
}
|
||||
|
||||
|
||||
getCommitData() {
|
||||
return {
|
||||
commit_message: this.vueInstance.conflictsData.commitMessage,
|
||||
sections: this.vueInstance.resolutionData
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getFilePath(file) {
|
||||
const { old_path, new_path } = file;
|
||||
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
//= require vue
|
||||
|
||||
class MergeConflictResolver {
|
||||
|
||||
constructor() {
|
||||
this.dataProvider = new MergeConflictDataProvider()
|
||||
this.initVue()
|
||||
}
|
||||
|
||||
|
||||
initVue() {
|
||||
const that = this;
|
||||
this.vue = new Vue({
|
||||
el : '#conflicts',
|
||||
name : 'MergeConflictResolver',
|
||||
data : this.dataProvider.getInitialData(),
|
||||
created : this.fetchData(),
|
||||
computed : this.setComputedProperties(),
|
||||
methods : {
|
||||
handleSelected(sectionId, selection) {
|
||||
that.dataProvider.handleSelected(sectionId, selection);
|
||||
},
|
||||
handleViewTypeChange(newType) {
|
||||
that.dataProvider.updateViewType(newType);
|
||||
},
|
||||
commit() {
|
||||
that.commit();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
setComputedProperties() {
|
||||
const dp = this.dataProvider;
|
||||
|
||||
return {
|
||||
conflictsCount() { return dp.getConflictsCount() },
|
||||
resolvedCount() { return dp.getResolvedCount() },
|
||||
readyToCommit() { return dp.isReadyToCommit() },
|
||||
commitButtonText() { return dp.getCommitButtonText() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchData() {
|
||||
const dp = this.dataProvider;
|
||||
|
||||
$.get($('#conflicts').data('conflictsPath'))
|
||||
.done((data) => {
|
||||
dp.decorateData(this.vue, data);
|
||||
})
|
||||
.error((data) => {
|
||||
dp.handleFailedRequest(this.vue, data);
|
||||
})
|
||||
.always(() => {
|
||||
this.vue.isLoading = false;
|
||||
|
||||
this.vue.$nextTick(() => {
|
||||
$('#conflicts .js-syntax-highlight').syntaxHighlight();
|
||||
});
|
||||
|
||||
if (this.vue.diffViewType === 'parallel') {
|
||||
$('.content-wrapper .container-fluid').removeClass('container-limited');
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
commit() {
|
||||
this.vue.isSubmitting = true;
|
||||
|
||||
$.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
|
||||
.done((data) => {
|
||||
window.location.href = data.redirect_to;
|
||||
})
|
||||
.error(() => {
|
||||
new Flash('Something went wrong!');
|
||||
})
|
||||
.always(() => {
|
||||
this.vue.isSubmitting = false;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
MergeRequest.prototype.initTabs = function() {
|
||||
if (this.opts.action !== 'new') {
|
||||
return new MergeRequestTabs(this.opts);
|
||||
window.mrTabs = new MergeRequestTabs(this.opts);
|
||||
} else {
|
||||
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
|
||||
}
|
||||
|
|
|
@ -9,10 +9,13 @@
|
|||
|
||||
MergeRequestTabs.prototype.buildsLoaded = false;
|
||||
|
||||
MergeRequestTabs.prototype.pipelinesLoaded = false;
|
||||
|
||||
MergeRequestTabs.prototype.commitsLoaded = false;
|
||||
|
||||
function MergeRequestTabs(opts) {
|
||||
this.opts = opts != null ? opts : {};
|
||||
this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true;
|
||||
this.setCurrentAction = bind(this.setCurrentAction, this);
|
||||
this.tabShown = bind(this.tabShown, this);
|
||||
this.showTab = bind(this.showTab, this);
|
||||
|
@ -50,10 +53,15 @@
|
|||
} else if (action === 'builds') {
|
||||
this.loadBuilds($target.attr('href'));
|
||||
this.expandView();
|
||||
} else if (action === 'pipelines') {
|
||||
this.loadPipelines($target.attr('href'));
|
||||
this.expandView();
|
||||
} else {
|
||||
this.expandView();
|
||||
}
|
||||
return this.setCurrentAction(action);
|
||||
if (this.opts.setUrl) {
|
||||
this.setCurrentAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
MergeRequestTabs.prototype.scrollToElement = function(container) {
|
||||
|
@ -81,7 +89,8 @@
|
|||
if (action === 'show') {
|
||||
action = 'notes';
|
||||
}
|
||||
new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, '');
|
||||
this.currentAction = action;
|
||||
new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
|
||||
if (action !== 'notes') {
|
||||
new_state += "/" + action;
|
||||
}
|
||||
|
@ -119,6 +128,11 @@
|
|||
success: (function(_this) {
|
||||
return function(data) {
|
||||
$('#diffs').html(data.html);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
|
||||
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
|
||||
$('#diffs .js-syntax-highlight').syntaxHighlight();
|
||||
$('#diffs .diff-file').singleFileDiff();
|
||||
|
@ -177,6 +191,21 @@
|
|||
});
|
||||
};
|
||||
|
||||
MergeRequestTabs.prototype.loadPipelines = function(source) {
|
||||
if (this.pipelinesLoaded) {
|
||||
return;
|
||||
}
|
||||
return this._get({
|
||||
url: source + ".json",
|
||||
success: function(data) {
|
||||
$('#pipelines').html(data.html);
|
||||
gl.utils.localTimeAgo($('.js-timeago', '#pipelines'));
|
||||
this.pipelinesLoaded = true;
|
||||
return this.scrollToElement("#pipelines");
|
||||
}.bind(this)
|
||||
});
|
||||
};
|
||||
|
||||
MergeRequestTabs.prototype.toggleLoading = function(status) {
|
||||
return $('.mr-loading-status .loading').toggle(status);
|
||||
};
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
MergeRequestWidget.prototype.addEventListeners = function() {
|
||||
var allowedPages;
|
||||
allowedPages = ['show', 'commits', 'builds', 'changes'];
|
||||
allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes'];
|
||||
return $(document).on('page:change.merge_request', (function(_this) {
|
||||
return function() {
|
||||
var page;
|
||||
|
@ -53,7 +53,7 @@
|
|||
return function(data) {
|
||||
var callback, urlSuffix;
|
||||
if (data.state === "merged") {
|
||||
urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
|
||||
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
|
||||
return window.location.href = window.location.pathname + urlSuffix;
|
||||
} else if (data.merge_error) {
|
||||
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
$selectbox.hide();
|
||||
return $value.css('display', '');
|
||||
},
|
||||
clicked: function(selected) {
|
||||
clicked: function(selected, $el, e) {
|
||||
var data, isIssueIndex, isMRIndex, page;
|
||||
page = $('body').data('page');
|
||||
isIssueIndex = page === 'projects:issues:index';
|
||||
|
@ -102,7 +102,11 @@
|
|||
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
||||
return;
|
||||
}
|
||||
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
if (page === 'projects:boards:show') {
|
||||
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
|
||||
gl.issueBoards.BoardsStore.updateFiltersUrl();
|
||||
e.preventDefault();
|
||||
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
if (selected.name != null) {
|
||||
selectedMilestone = selected.name;
|
||||
} else {
|
||||
|
|
|
@ -68,6 +68,7 @@
|
|||
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
||||
$(document).on("click", ".js-comment-button", this.updateCloseButton);
|
||||
$(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-attachment-delete", this.removeAttachment);
|
||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||
|
@ -100,6 +101,7 @@
|
|||
$(document).off("click", ".js-note-target-close");
|
||||
$(document).off("click", ".js-note-discard");
|
||||
$(document).off("keydown", ".js-note-text");
|
||||
$(document).off('click', '.js-comment-resolve-button');
|
||||
$('.note .js-task-list-container').taskList('disable');
|
||||
return $(document).off('tasklist:changed', '.note .js-task-list-container');
|
||||
};
|
||||
|
@ -201,7 +203,7 @@
|
|||
Increase @pollingInterval up to 120 seconds on every function call,
|
||||
if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
|
||||
will reset to @basePollingInterval.
|
||||
|
||||
|
||||
Note: this function is used to gradually increase the polling interval
|
||||
if there aren't new notes coming from the server
|
||||
*/
|
||||
|
@ -223,7 +225,7 @@
|
|||
|
||||
/*
|
||||
Render note in main comments area.
|
||||
|
||||
|
||||
Note: for rendering inline notes use renderDiscussionNote
|
||||
*/
|
||||
|
||||
|
@ -231,7 +233,13 @@
|
|||
var $notesList, votesBlock;
|
||||
if (!note.valid) {
|
||||
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;
|
||||
}
|
||||
|
@ -245,6 +253,7 @@
|
|||
$notesList.append(note.html).syntaxHighlight();
|
||||
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
|
||||
this.initTaskList();
|
||||
this.refresh();
|
||||
return this.updateNotesCount(1);
|
||||
}
|
||||
};
|
||||
|
@ -265,7 +274,7 @@
|
|||
|
||||
/*
|
||||
Render note in discussion area.
|
||||
|
||||
|
||||
Note: for rendering inline notes use renderDiscussionNote
|
||||
*/
|
||||
|
||||
|
@ -297,6 +306,11 @@
|
|||
} else {
|
||||
discussionContainer.append(note_html);
|
||||
}
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
|
||||
gl.utils.localTimeAgo($('.js-timeago', note_html), false);
|
||||
return this.updateNotesCount(1);
|
||||
};
|
||||
|
@ -304,7 +318,7 @@
|
|||
|
||||
/*
|
||||
Called in response the main target form has been successfully submitted.
|
||||
|
||||
|
||||
Removes any errors.
|
||||
Resets text and preview.
|
||||
Resets buttons.
|
||||
|
@ -329,7 +343,7 @@
|
|||
|
||||
/*
|
||||
Shows the main form and does some setup on it.
|
||||
|
||||
|
||||
Sets some hidden fields in the form.
|
||||
*/
|
||||
|
||||
|
@ -343,13 +357,14 @@
|
|||
form.find("#note_line_code").remove();
|
||||
form.find("#note_position").remove();
|
||||
form.find("#note_type").remove();
|
||||
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
|
||||
return this.parentTimeline = form.parents('.timeline');
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
General note form setup.
|
||||
|
||||
|
||||
deactivates the submit button when text is empty
|
||||
hides the preview button when text is empty
|
||||
setup GFM auto complete
|
||||
|
@ -366,7 +381,7 @@
|
|||
|
||||
/*
|
||||
Called in response to the new note form being submitted
|
||||
|
||||
|
||||
Adds new note to list.
|
||||
*/
|
||||
|
||||
|
@ -381,19 +396,33 @@
|
|||
|
||||
/*
|
||||
Called in response to the new note form being submitted
|
||||
|
||||
|
||||
Adds new note to list.
|
||||
*/
|
||||
|
||||
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);
|
||||
return this.removeDiscussionNoteForm($(xhr.target));
|
||||
this.removeDiscussionNoteForm($form);
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called in response to the edit note form being submitted
|
||||
|
||||
|
||||
Updates the current note field.
|
||||
*/
|
||||
|
||||
|
@ -404,13 +433,18 @@
|
|||
$html.syntaxHighlight();
|
||||
$html.find('.js-task-list-container').taskList('enable');
|
||||
$note_li = $('.note-row-' + note.id);
|
||||
return $note_li.replaceWith($html);
|
||||
|
||||
$note_li.replaceWith($html);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called in response to clicking the edit note link
|
||||
|
||||
|
||||
Replaces the note text with the note edit form
|
||||
Adds a data attribute to the form with the original content of the note for cancellations
|
||||
*/
|
||||
|
@ -450,7 +484,7 @@
|
|||
|
||||
/*
|
||||
Called in response to clicking the edit note link
|
||||
|
||||
|
||||
Hides edit form and restores the original note text to the editor textarea.
|
||||
*/
|
||||
|
||||
|
@ -472,7 +506,7 @@
|
|||
|
||||
/*
|
||||
Called in response to deleting a note of any kind.
|
||||
|
||||
|
||||
Removes the actual note from view.
|
||||
Removes the whole discussion if the last note is being removed.
|
||||
*/
|
||||
|
@ -485,6 +519,15 @@
|
|||
var note, notes;
|
||||
note = $(el);
|
||||
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) {
|
||||
notes.closest(".timeline-entry").remove();
|
||||
notes.closest("tr").remove();
|
||||
|
@ -498,7 +541,7 @@
|
|||
|
||||
/*
|
||||
Called in response to clicking the delete attachment link
|
||||
|
||||
|
||||
Removes the attachment wrapper view, including image tag if it exists
|
||||
Resets the note editing form
|
||||
*/
|
||||
|
@ -515,7 +558,7 @@
|
|||
|
||||
/*
|
||||
Called when clicking on the "reply" button for a diff line.
|
||||
|
||||
|
||||
Shows the note form below the notes.
|
||||
*/
|
||||
|
||||
|
@ -523,17 +566,19 @@
|
|||
var form, replyLink;
|
||||
form = this.formClone.clone();
|
||||
replyLink = $(e.target).closest(".js-discussion-reply-button");
|
||||
replyLink.hide();
|
||||
replyLink.after(form);
|
||||
replyLink
|
||||
.closest('.discussion-reply-holder')
|
||||
.hide()
|
||||
.after(form);
|
||||
return this.setupDiscussionNoteForm(replyLink, form);
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Shows the diff or discussion form and does some setup on it.
|
||||
|
||||
|
||||
Sets some hidden fields in the form.
|
||||
|
||||
|
||||
Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
|
||||
and "noteableId" data attributes set.
|
||||
*/
|
||||
|
@ -549,15 +594,29 @@
|
|||
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
|
||||
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-target-close').remove();
|
||||
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();
|
||||
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");
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
Called when clicking on the "add a comment" button on the side of a diff line.
|
||||
|
||||
|
||||
Inserts a temporary row for the form below the line.
|
||||
Sets up the form and shows it.
|
||||
*/
|
||||
|
@ -570,16 +629,19 @@
|
|||
nextRow = row.next();
|
||||
hasNotes = nextRow.is(".notes_holder");
|
||||
addForm = false;
|
||||
targetContent = ".notes_content";
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>";
|
||||
notesContentSelector = ".notes_content";
|
||||
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()) {
|
||||
lineType = $link.data("lineType");
|
||||
targetContent += "." + 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>";
|
||||
notesContentSelector += "." + lineType;
|
||||
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) {
|
||||
notesContent = nextRow.find(targetContent);
|
||||
nextRow.show();
|
||||
notesContent = nextRow.find(notesContentSelector);
|
||||
if (notesContent.length) {
|
||||
notesContent.show();
|
||||
replyButton = notesContent.find(".js-discussion-reply-button:visible");
|
||||
if (replyButton.length) {
|
||||
e.target = replyButton[0];
|
||||
|
@ -593,11 +655,13 @@
|
|||
}
|
||||
} else {
|
||||
row.after(rowCssToAdd);
|
||||
nextRow = row.next();
|
||||
notesContent = nextRow.find(notesContentSelector);
|
||||
addForm = true;
|
||||
}
|
||||
if (addForm) {
|
||||
newForm = this.formClone.clone();
|
||||
newForm.appendTo(row.next().find(targetContent));
|
||||
newForm.appendTo(notesContent);
|
||||
return this.setupDiscussionNoteForm($link, newForm);
|
||||
}
|
||||
};
|
||||
|
@ -605,7 +669,7 @@
|
|||
|
||||
/*
|
||||
Called in response to "cancel" on a diff note form.
|
||||
|
||||
|
||||
Shows the reply button again.
|
||||
Removes the form and if necessary it's temporary row.
|
||||
*/
|
||||
|
@ -616,7 +680,9 @@
|
|||
glForm = form.data('gl-form');
|
||||
glForm.destroy();
|
||||
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")) {
|
||||
return row.remove();
|
||||
} else {
|
||||
|
@ -634,7 +700,7 @@
|
|||
|
||||
/*
|
||||
Called after an attachment file has been selected.
|
||||
|
||||
|
||||
Updates the file name for the selected attachment.
|
||||
*/
|
||||
|
||||
|
@ -725,6 +791,18 @@
|
|||
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;
|
||||
|
||||
})();
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
(function() {
|
||||
function toggleGraph() {
|
||||
const $pipelineBtn = $(this).closest('.toggle-pipeline-btn');
|
||||
const $pipelineGraph = $(this).closest('.row-content-block').next('.pipeline-graph');
|
||||
const $btnText = $(this).find('.toggle-btn-text');
|
||||
|
||||
$($pipelineBtn).add($pipelineGraph).toggleClass('graph-collapsed');
|
||||
|
||||
const graphCollapsed = $pipelineGraph.hasClass('graph-collapsed');
|
||||
|
||||
graphCollapsed ? $btnText.text('Expand') : $btnText.text('Hide')
|
||||
}
|
||||
|
||||
$(document).on('click', '.toggle-pipeline-btn', toggleGraph);
|
||||
})();
|
|
@ -35,10 +35,16 @@
|
|||
this.isOpen = !this.isOpen;
|
||||
if (!this.isOpen && !this.hasError) {
|
||||
this.content.hide();
|
||||
return this.collapsedContent.show();
|
||||
this.collapsedContent.show();
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
} else if (this.content) {
|
||||
this.collapsedContent.hide();
|
||||
return this.content.show();
|
||||
this.content.show();
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
} else {
|
||||
return this.getContentHTML();
|
||||
}
|
||||
|
@ -57,7 +63,11 @@
|
|||
_this.hasError = true;
|
||||
_this.content = $(ERROR_HTML);
|
||||
}
|
||||
return _this.collapsedContent.after(_this.content);
|
||||
_this.collapsedContent.after(_this.content);
|
||||
|
||||
if (typeof DiffNotesApp !== 'undefined') {
|
||||
DiffNotesApp.compileComponents();
|
||||
}
|
||||
};
|
||||
})(this));
|
||||
};
|
||||
|
|
|
@ -141,7 +141,7 @@
|
|||
$selectbox.hide();
|
||||
return $value.css('display', '');
|
||||
},
|
||||
clicked: function(user) {
|
||||
clicked: function(user, $el, e) {
|
||||
var isIssueIndex, isMRIndex, page, selected;
|
||||
page = $('body').data('page');
|
||||
isIssueIndex = page === 'projects:issues:index';
|
||||
|
@ -149,7 +149,12 @@
|
|||
if ($dropdown.hasClass('js-filter-bulk-update')) {
|
||||
return;
|
||||
}
|
||||
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
if (page === 'projects:boards:show') {
|
||||
selectedId = user.id;
|
||||
gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
|
||||
gl.issueBoards.BoardsStore.updateFiltersUrl();
|
||||
e.preventDefault();
|
||||
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
|
||||
selectedId = user.id;
|
||||
return Issuable.filterResults($dropdown.closest('form'));
|
||||
} else if ($dropdown.hasClass('js-filter-submit')) {
|
||||
|
|
|
@ -20,3 +20,8 @@
|
|||
.turn-off { display: block; }
|
||||
}
|
||||
}
|
||||
|
||||
// Hide element if Vue is still working on rendering it fully.
|
||||
[v-cloak="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
@ -204,6 +204,10 @@
|
|||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
svg, .fa {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
|
|
|
@ -147,3 +147,8 @@
|
|||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.atwho-view small.description {
|
||||
float: right;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
|
|
@ -123,4 +123,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark-diff-match-line {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
|
|
@ -222,3 +222,7 @@ header.header-pinned-nav {
|
|||
padding-right: $sidebar_collapsed_width;
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar {
|
||||
border-left: 1px solid $border-color;
|
||||
}
|
||||
|
|
|
@ -276,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
|
|||
|
||||
$ci-output-bg: #1d1f21;
|
||||
$ci-text-color: #c5c8c6;
|
||||
|
||||
$issue-boards-font-size: 15px;
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
// Diff line
|
||||
.line_holder {
|
||||
&.match .line_content {
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
|
||||
td.diff-line-num.hll:not(.empty-cell),
|
||||
td.line_content.hll:not(.empty-cell) {
|
||||
background-color: #557;
|
||||
|
@ -36,8 +40,7 @@
|
|||
}
|
||||
|
||||
.line_content.match {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
// Diff line
|
||||
.line_holder {
|
||||
&.match .line_content {
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
|
||||
td.diff-line-num.hll:not(.empty-cell),
|
||||
td.line_content.hll:not(.empty-cell) {
|
||||
background-color: #49483e;
|
||||
|
@ -36,8 +40,7 @@
|
|||
}
|
||||
|
||||
.line_content.match {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
// Diff line
|
||||
.line_holder {
|
||||
&.match .line_content {
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
|
||||
td.diff-line-num.hll:not(.empty-cell),
|
||||
td.line_content.hll:not(.empty-cell) {
|
||||
background-color: #174652;
|
||||
|
@ -36,8 +40,7 @@
|
|||
}
|
||||
|
||||
.line_content.match {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@include dark-diff-match-line;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/* https://gist.github.com/qguv/7936275 */
|
||||
|
||||
@mixin matchLine {
|
||||
color: $black-transparent;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.code.solarized-light {
|
||||
// Line numbers
|
||||
.line-numbers, .diff-line-num {
|
||||
|
@ -21,6 +27,10 @@
|
|||
|
||||
// Diff line
|
||||
.line_holder {
|
||||
&.match .line_content {
|
||||
@include matchLine;
|
||||
}
|
||||
|
||||
td.diff-line-num.hll:not(.empty-cell),
|
||||
td.line_content.hll:not(.empty-cell) {
|
||||
background-color: #ddd8c5;
|
||||
|
@ -36,8 +46,7 @@
|
|||
}
|
||||
|
||||
.line_content.match {
|
||||
color: $black-transparent;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
@include matchLine;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/* https://github.com/aahan/pygments-github-style */
|
||||
|
||||
@mixin matchLine {
|
||||
color: $black-transparent;
|
||||
background-color: $match-line;
|
||||
}
|
||||
|
||||
.code.white {
|
||||
// Line numbers
|
||||
.line-numbers, .diff-line-num {
|
||||
|
@ -22,6 +28,10 @@
|
|||
// Diff line
|
||||
.line_holder {
|
||||
|
||||
&.match .line_content {
|
||||
@include matchLine;
|
||||
}
|
||||
|
||||
.diff-line-num {
|
||||
&.old {
|
||||
background-color: $line-number-old;
|
||||
|
@ -57,8 +67,7 @@
|
|||
}
|
||||
|
||||
&.match {
|
||||
color: $black-transparent;
|
||||
background-color: $match-line;
|
||||
@include matchLine;
|
||||
}
|
||||
|
||||
&.hll:not(.empty-cell) {
|
||||
|
|
|
@ -45,7 +45,6 @@
|
|||
.line_content {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
white-space: pre;
|
||||
|
||||
&.old {
|
||||
background-color: $line-removed;
|
||||
|
@ -71,6 +70,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span.highlight_word {
|
||||
background-color: #fafe3d !important;
|
||||
}
|
||||
|
|
|
@ -72,7 +72,6 @@
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
// Users List
|
||||
|
||||
.users-list {
|
||||
|
@ -98,3 +97,44 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.abuse-reports {
|
||||
.table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
.subheading {
|
||||
padding-bottom: $gl-padding;
|
||||
}
|
||||
.message {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: $gl-btn-padding;
|
||||
}
|
||||
th {
|
||||
width: 15%;
|
||||
&.wide {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
@media (max-width: $screen-sm-max) {
|
||||
th {
|
||||
width: 100%;
|
||||
}
|
||||
td {
|
||||
width: 100%;
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.no-reports {
|
||||
.emoji-icon {
|
||||
margin-left: $btn-side-margin;
|
||||
margin-top: 3px;
|
||||
}
|
||||
span {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,329 @@
|
|||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-can-drag {
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.is-dragging {
|
||||
* {
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-issues-board-new {
|
||||
width: 320px;
|
||||
|
||||
.dropdown-content {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-board-dropdown-content {
|
||||
margin: 0 8px 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid $dropdown-divider-color;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #9c9c9c;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-boards-page {
|
||||
.content-wrapper {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sub-nav,
|
||||
.issues-filters {
|
||||
-webkit-flex: none;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.page-with-sidebar {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
max-height: 100vh;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.issue-boards-content {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex: 1;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
.content {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.boards-app-loading {
|
||||
width: 100%;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.boards-list {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex: 1;
|
||||
flex: 1;
|
||||
-webkit-flex-basis: 0;
|
||||
flex-basis: 0;
|
||||
min-height: calc(100vh - 152px);
|
||||
max-height: calc(100vh - 152px);
|
||||
padding-top: 25px;
|
||||
padding-right: ($gl-padding / 2);
|
||||
padding-left: ($gl-padding / 2);
|
||||
overflow-x: scroll;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
min-height: 475px;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.board {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
min-width: calc(100vw - 15px);
|
||||
max-width: calc(100vw - 15px);
|
||||
margin-bottom: 25px;
|
||||
padding-right: ($gl-padding / 2);
|
||||
padding-left: ($gl-padding / 2);
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-inner {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-size: $issue-boards-font-size;
|
||||
background: $background-color;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $border-radius-default;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
border-top-left-radius: $border-radius-default;
|
||||
border-top-right-radius: $border-radius-default;
|
||||
|
||||
&.has-border {
|
||||
border-top: 3px solid;
|
||||
|
||||
.board-title {
|
||||
padding-top: ($gl-padding - 3px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.board-header-loading-spinner {
|
||||
margin-right: 10px;
|
||||
color: $gray-darkest;
|
||||
}
|
||||
|
||||
.board-inner-container {
|
||||
border-bottom: 1px solid $border-color;
|
||||
padding: $gl-padding;
|
||||
}
|
||||
|
||||
.board-title {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: $gl-padding;
|
||||
font-size: 1em;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
.board-mobile-handle {
|
||||
position: relative;
|
||||
left: 0;
|
||||
top: 1px;
|
||||
margin-top: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-search-container {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
|
||||
.form-control {
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-search-icon,
|
||||
.board-search-clear-btn {
|
||||
position: absolute;
|
||||
right: $gl-padding + 10px;
|
||||
top: 50%;
|
||||
margin-top: -7px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.board-search-icon {
|
||||
color: $gl-placeholder-color;
|
||||
}
|
||||
|
||||
.board-search-clear-btn {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
|
||||
&:hover {
|
||||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.board-delete {
|
||||
margin-right: 10px;
|
||||
padding: 0;
|
||||
color: $gray-darkest;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
|
||||
&:hover {
|
||||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.board-blank-state {
|
||||
height: 100%;
|
||||
padding: $gl-padding;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.board-blank-state-list {
|
||||
list-style: none;
|
||||
|
||||
> li:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.label-color {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 3px;
|
||||
border-radius: $border-radius-default;
|
||||
}
|
||||
}
|
||||
|
||||
.board-list {
|
||||
-webkit-flex: 1;
|
||||
flex: 1;
|
||||
height: 400px;
|
||||
margin-bottom: 0;
|
||||
padding: 5px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.board-list-loading {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.is-ghost {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.is-dragging {
|
||||
// Important because plugin sets inline CSS
|
||||
opacity: 1!important;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 10px $gl-padding;
|
||||
background: #fff;
|
||||
border-radius: $border-radius-default;
|
||||
box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5);
|
||||
list-style: none;
|
||||
|
||||
&.user-can-drag {
|
||||
padding-left: ($gl-padding * 2);
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
padding-left: $gl-padding;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.label {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.confidential-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-mobile-handle {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
margin-top: (-15px / 2);
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 5px;
|
||||
|
||||
.label {
|
||||
margin-right: 4px;
|
||||
font-size: (14px / $issue-boards-font-size) * 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.card-number {
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
|
@ -53,14 +53,6 @@
|
|||
left: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.build-header {
|
||||
|
@ -108,24 +100,98 @@
|
|||
}
|
||||
|
||||
.right-sidebar.build-sidebar {
|
||||
padding-top: $gl-padding;
|
||||
padding-bottom: $gl-padding;
|
||||
padding: $gl-padding 0;
|
||||
|
||||
&.right-sidebar-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blocks-container {
|
||||
padding: $gl-padding;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.build-sidebar-header {
|
||||
padding-top: 0;
|
||||
padding: 0 $gl-padding $gl-padding;
|
||||
|
||||
.gutter-toggle {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-item {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $gl-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.build-dropdown {
|
||||
padding: 0 $gl-padding;
|
||||
|
||||
.dropdown-menu-toggle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: $gl-padding;
|
||||
left: $gl-padding;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.builds-container {
|
||||
margin-top: $gl-padding;
|
||||
background-color: $white-light;
|
||||
border-top: 1px solid $border-color;
|
||||
border-bottom: 1px solid $border-color;
|
||||
max-height: 300px;
|
||||
overflow: scroll;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 3px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: $gl-padding 10px $gl-padding 40px;
|
||||
width: 270px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background-color: $row-hover;
|
||||
color: $gl-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.build-job {
|
||||
position: relative;
|
||||
|
||||
.fa {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.build-detail-row {
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
$colors: (
|
||||
white_header_head_neutral : #e1fad7,
|
||||
white_line_head_neutral : #effdec,
|
||||
white_button_head_neutral : #9adb84,
|
||||
|
||||
white_header_head_chosen : #baf0a8,
|
||||
white_line_head_chosen : #e1fad7,
|
||||
white_button_head_chosen : #52c22d,
|
||||
|
||||
white_header_origin_neutral : #e0f0ff,
|
||||
white_line_origin_neutral : #f2f9ff,
|
||||
white_button_origin_neutral : #87c2fa,
|
||||
|
||||
white_header_origin_chosen : #add8ff,
|
||||
white_line_origin_chosen : #e0f0ff,
|
||||
white_button_origin_chosen : #268ced,
|
||||
|
||||
white_header_not_chosen : #f0f0f0,
|
||||
white_line_not_chosen : #f9f9f9,
|
||||
|
||||
|
||||
dark_header_head_neutral : rgba(#3f3, .2),
|
||||
dark_line_head_neutral : rgba(#3f3, .1),
|
||||
dark_button_head_neutral : #40874f,
|
||||
|
||||
dark_header_head_chosen : rgba(#3f3, .33),
|
||||
dark_line_head_chosen : rgba(#3f3, .2),
|
||||
dark_button_head_chosen : #258537,
|
||||
|
||||
dark_header_origin_neutral : rgba(#2878c9, .4),
|
||||
dark_line_origin_neutral : rgba(#2878c9, .3),
|
||||
dark_button_origin_neutral : #2a5c8c,
|
||||
|
||||
dark_header_origin_chosen : rgba(#2878c9, .6),
|
||||
dark_line_origin_chosen : rgba(#2878c9, .4),
|
||||
dark_button_origin_chosen : #1d6cbf,
|
||||
|
||||
dark_header_not_chosen : rgba(#fff, .25),
|
||||
dark_line_not_chosen : rgba(#fff, .1),
|
||||
|
||||
|
||||
monokai_header_head_neutral : rgba(#a6e22e, .25),
|
||||
monokai_line_head_neutral : rgba(#a6e22e, .1),
|
||||
monokai_button_head_neutral : #376b20,
|
||||
|
||||
monokai_header_head_chosen : rgba(#a6e22e, .4),
|
||||
monokai_line_head_chosen : rgba(#a6e22e, .25),
|
||||
monokai_button_head_chosen : #39800d,
|
||||
|
||||
monokai_header_origin_neutral : rgba(#60d9f1, .35),
|
||||
monokai_line_origin_neutral : rgba(#60d9f1, .15),
|
||||
monokai_button_origin_neutral : #38848c,
|
||||
|
||||
monokai_header_origin_chosen : rgba(#60d9f1, .5),
|
||||
monokai_line_origin_chosen : rgba(#60d9f1, .35),
|
||||
monokai_button_origin_chosen : #3ea4b2,
|
||||
|
||||
monokai_header_not_chosen : rgba(#76715d, .24),
|
||||
monokai_line_not_chosen : rgba(#76715d, .1),
|
||||
|
||||
|
||||
solarized_light_header_head_neutral : rgba(#859900, .37),
|
||||
solarized_light_line_head_neutral : rgba(#859900, .2),
|
||||
solarized_light_button_head_neutral : #afb262,
|
||||
|
||||
solarized_light_header_head_chosen : rgba(#859900, .5),
|
||||
solarized_light_line_head_chosen : rgba(#859900, .37),
|
||||
solarized_light_button_head_chosen : #94993d,
|
||||
|
||||
solarized_light_header_origin_neutral : rgba(#2878c9, .37),
|
||||
solarized_light_line_origin_neutral : rgba(#2878c9, .15),
|
||||
solarized_light_button_origin_neutral : #60a1bf,
|
||||
|
||||
solarized_light_header_origin_chosen : rgba(#2878c9, .6),
|
||||
solarized_light_line_origin_chosen : rgba(#2878c9, .37),
|
||||
solarized_light_button_origin_chosen : #2482b2,
|
||||
|
||||
solarized_light_header_not_chosen : rgba(#839496, .37),
|
||||
solarized_light_line_not_chosen : rgba(#839496, .2),
|
||||
|
||||
|
||||
solarized_dark_header_head_neutral : rgba(#859900, .35),
|
||||
solarized_dark_line_head_neutral : rgba(#859900, .15),
|
||||
solarized_dark_button_head_neutral : #376b20,
|
||||
|
||||
solarized_dark_header_head_chosen : rgba(#859900, .5),
|
||||
solarized_dark_line_head_chosen : rgba(#859900, .35),
|
||||
solarized_dark_button_head_chosen : #39800d,
|
||||
|
||||
solarized_dark_header_origin_neutral : rgba(#2878c9, .35),
|
||||
solarized_dark_line_origin_neutral : rgba(#2878c9, .15),
|
||||
solarized_dark_button_origin_neutral : #086799,
|
||||
|
||||
solarized_dark_header_origin_chosen : rgba(#2878c9, .6),
|
||||
solarized_dark_line_origin_chosen : rgba(#2878c9, .35),
|
||||
solarized_dark_button_origin_chosen : #0082cc,
|
||||
|
||||
solarized_dark_header_not_chosen : rgba(#839496, .25),
|
||||
solarized_dark_line_not_chosen : rgba(#839496, .15)
|
||||
);
|
||||
|
||||
|
||||
@mixin color-scheme($color) {
|
||||
.header.line_content, .diff-line-num {
|
||||
&.origin {
|
||||
background-color: map-get($colors, #{$color}_header_origin_neutral);
|
||||
border-color: map-get($colors, #{$color}_header_origin_neutral);
|
||||
|
||||
button {
|
||||
background-color: map-get($colors, #{$color}_button_origin_neutral);
|
||||
border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: map-get($colors, #{$color}_header_origin_chosen);
|
||||
border-color: map-get($colors, #{$color}_header_origin_chosen);
|
||||
|
||||
button {
|
||||
background-color: map-get($colors, #{$color}_button_origin_chosen);
|
||||
border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15);
|
||||
}
|
||||
}
|
||||
|
||||
&.unselected {
|
||||
background-color: map-get($colors, #{$color}_header_not_chosen);
|
||||
border-color: map-get($colors, #{$color}_header_not_chosen);
|
||||
|
||||
button {
|
||||
background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15);
|
||||
border-color: map-get($colors, #{$color}_button_origin_neutral);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.head {
|
||||
background-color: map-get($colors, #{$color}_header_head_neutral);
|
||||
border-color: map-get($colors, #{$color}_header_head_neutral);
|
||||
|
||||
button {
|
||||
background-color: map-get($colors, #{$color}_button_head_neutral);
|
||||
border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: map-get($colors, #{$color}_header_head_chosen);
|
||||
border-color: map-get($colors, #{$color}_header_head_chosen);
|
||||
|
||||
button {
|
||||
background-color: map-get($colors, #{$color}_button_head_chosen);
|
||||
border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15);
|
||||
}
|
||||
}
|
||||
|
||||
&.unselected {
|
||||
background-color: map-get($colors, #{$color}_header_not_chosen);
|
||||
border-color: map-get($colors, #{$color}_header_not_chosen);
|
||||
|
||||
button {
|
||||
background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15);
|
||||
border-color: map-get($colors, #{$color}_button_head_neutral);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.line_content {
|
||||
&.origin {
|
||||
background-color: map-get($colors, #{$color}_line_origin_neutral);
|
||||
|
||||
&.selected {
|
||||
background-color: map-get($colors, #{$color}_line_origin_chosen);
|
||||
}
|
||||
|
||||
&.unselected {
|
||||
background-color: map-get($colors, #{$color}_line_not_chosen);
|
||||
}
|
||||
}
|
||||
&.head {
|
||||
background-color: map-get($colors, #{$color}_line_head_neutral);
|
||||
|
||||
&.selected {
|
||||
background-color: map-get($colors, #{$color}_line_head_chosen);
|
||||
}
|
||||
|
||||
&.unselected {
|
||||
background-color: map-get($colors, #{$color}_line_not_chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#conflicts {
|
||||
|
||||
.white {
|
||||
@include color-scheme('white')
|
||||
}
|
||||
|
||||
.dark {
|
||||
@include color-scheme('dark')
|
||||
}
|
||||
|
||||
.monokai {
|
||||
@include color-scheme('monokai')
|
||||
}
|
||||
|
||||
.solarized-light {
|
||||
@include color-scheme('solarized_light')
|
||||
}
|
||||
|
||||
.solarized-dark {
|
||||
@include color-scheme('solarized_dark')
|
||||
}
|
||||
|
||||
.diff-wrap-lines .line_content {
|
||||
white-space: normal;
|
||||
min-height: 19px;
|
||||
}
|
||||
|
||||
.line_content.header {
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
width: 75px; // static width to make 2 buttons have same width
|
||||
height: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-success .fa-spinner {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
|
@ -383,3 +383,80 @@ ul.notes {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -229,3 +229,196 @@
|
|||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline visualization
|
||||
|
||||
.toggle-pipeline-btn {
|
||||
background-color: $gray-dark;
|
||||
|
||||
.caret {
|
||||
border-top: none;
|
||||
border-bottom: 4px solid;
|
||||
}
|
||||
|
||||
&.graph-collapsed {
|
||||
background-color: $white-light;
|
||||
|
||||
.caret {
|
||||
border-bottom: none;
|
||||
border-top: 4px solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pipeline-graph {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
max-height: 500px;
|
||||
transition: max-height 0.3s, padding 0.3s;
|
||||
|
||||
&.graph-collapsed {
|
||||
max-height: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.pipeline-visualization {
|
||||
position: relative;
|
||||
min-width: 1220px;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-column {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: 50px;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
margin-bottom: 15px;
|
||||
font-weight: bold;
|
||||
width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.build {
|
||||
border: 1px solid $border-color;
|
||||
position: relative;
|
||||
padding: 6px 10px;
|
||||
border-radius: 30px;
|
||||
width: 150px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&.playable {
|
||||
background-color: $gray-light;
|
||||
}
|
||||
|
||||
.build-content {
|
||||
width: 130px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
a {
|
||||
color: $layout-link-gray;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.fa {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Connect first build in each stage with right horizontal line
|
||||
&:first-child {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -54px;
|
||||
border-top: 2px solid $border-color;
|
||||
width: 54px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect each build (except for first) with curved lines
|
||||
&:not(:first-child) {
|
||||
&::after, &::before {
|
||||
content: '';
|
||||
top: -47px;
|
||||
position: absolute;
|
||||
border-bottom: 2px solid $border-color;
|
||||
width: 20px;
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
// Right connecting curves
|
||||
&::after {
|
||||
right: -20px;
|
||||
border-right: 2px solid $border-color;
|
||||
border-radius: 0 0 50px;
|
||||
}
|
||||
|
||||
// Left connecting curves
|
||||
&::before {
|
||||
left: -20px;
|
||||
border-left: 2px solid $border-color;
|
||||
border-radius: 0 0 0 50px;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect second build to first build with smaller curved line
|
||||
&:nth-child(2) {
|
||||
&::after, &::before {
|
||||
height: 45px;
|
||||
top: -26px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.build {
|
||||
// Remove right connecting horizontal line from first build in last stage
|
||||
&:first-child {
|
||||
&::after, &::before {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
// Remove right curved connectors from all builds in last stage
|
||||
&:not(:first-child) {
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.build {
|
||||
// Remove left curved connectors from all builds in first stage
|
||||
&:not(:first-child) {
|
||||
&::before {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pipeline-actions {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.toggle-pipeline-btn {
|
||||
|
||||
.fa {
|
||||
color: $dropdown-header-color;
|
||||
}
|
||||
}
|
||||
|
||||
.pipelines.tab-pane {
|
||||
|
||||
.content-list.pipelines {
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.stage {
|
||||
max-width: 60px;
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -228,3 +228,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.u2f-registrations {
|
||||
th:not(:last-child), td:not(:last-child) {
|
||||
border-right: solid 1px transparent;
|
||||
}
|
||||
}
|
|
@ -35,19 +35,13 @@ class AutocompleteController < ApplicationController
|
|||
|
||||
def projects
|
||||
project = Project.find_by_id(params[:project_id])
|
||||
|
||||
projects = current_user.authorized_projects
|
||||
projects = projects.search(params[:search]) if params[:search].present?
|
||||
projects = projects.select do |project|
|
||||
current_user.can?(:admin_issue, project)
|
||||
end
|
||||
projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
|
||||
|
||||
no_project = {
|
||||
id: 0,
|
||||
name_with_namespace: 'No project',
|
||||
}
|
||||
projects.unshift(no_project)
|
||||
projects.delete(project)
|
||||
projects.unshift(no_project) unless params[:offset_id].present?
|
||||
|
||||
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
|
||||
end
|
||||
|
@ -79,4 +73,8 @@ class AutocompleteController < ApplicationController
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def projects_finder
|
||||
MoveToProjectFinder.new(current_user)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -66,6 +66,11 @@ module IssuableCollections
|
|||
key = 'issuable_sort'
|
||||
|
||||
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]
|
||||
end
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
|
|||
end
|
||||
|
||||
def destroy
|
||||
TodoService.new.mark_todos_as_done([todo], current_user)
|
||||
TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
|
||||
|
@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def todo
|
||||
@todo ||= find_todos.find(params[:id])
|
||||
end
|
||||
|
||||
def find_todos
|
||||
@todos ||= TodosFinder.new(current_user, params).execute
|
||||
end
|
||||
|
|
|
@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# A U2F (universal 2nd factor) device's information is stored after successful
|
||||
# registration, which is then used while 2FA authentication is taking place.
|
||||
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?
|
||||
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
|
||||
@qr_code = build_qr_code
|
||||
setup_u2f_registration
|
||||
|
@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
# Actual communication is performed using a Javascript API
|
||||
def setup_u2f_registration
|
||||
@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)
|
||||
|
||||
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)
|
||||
|
||||
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
|
||||
register_requests: registration_requests,
|
||||
sign_requests: sign_requests })
|
||||
end
|
||||
|
||||
def u2f_registration_params
|
||||
params.require(:u2f_registration).permit(:device_response, :name)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
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
|
|
@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController
|
|||
end
|
||||
|
||||
def apply_diff_view_cookie!
|
||||
@show_changes_tab = params[:view].present?
|
||||
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
class Projects::BoardListsController < Projects::ApplicationController
|
||||
respond_to :json
|
||||
|
||||
before_action :authorize_admin_list!
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||
|
||||
def create
|
||||
list = Boards::Lists::CreateService.new(project, current_user, list_params).execute
|
||||
|
||||
if list.valid?
|
||||
render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
|
||||
else
|
||||
render json: list.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
service = Boards::Lists::MoveService.new(project, current_user, move_params)
|
||||
|
||||
if service.execute
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
service = Boards::Lists::DestroyService.new(project, current_user, params)
|
||||
|
||||
if service.execute
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
service = Boards::Lists::GenerateService.new(project, current_user)
|
||||
|
||||
if service.execute
|
||||
render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_admin_list!
|
||||
return render_403 unless can?(current_user, :admin_list, project)
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:list).permit(:label_id)
|
||||
end
|
||||
|
||||
def move_params
|
||||
params.require(:list).permit(:position).merge(id: params[:id])
|
||||
end
|
||||
|
||||
def record_not_found(exception)
|
||||
render json: { error: exception.message }, status: :not_found
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
module Projects
|
||||
module Boards
|
||||
class ApplicationController < Projects::ApplicationController
|
||||
respond_to :json
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
||||
|
||||
private
|
||||
|
||||
def record_not_found(exception)
|
||||
render json: { error: exception.message }, status: :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,56 @@
|
|||
module Projects
|
||||
module Boards
|
||||
class IssuesController < Boards::ApplicationController
|
||||
before_action :authorize_read_issue!, only: [:index]
|
||||
before_action :authorize_update_issue!, only: [:update]
|
||||
|
||||
def index
|
||||
issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
|
||||
issues = issues.page(params[:page])
|
||||
|
||||
render json: issues.as_json(
|
||||
only: [:iid, :title, :confidential],
|
||||
include: {
|
||||
assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
|
||||
labels: { only: [:id, :title, :description, :color, :priority] }
|
||||
})
|
||||
end
|
||||
|
||||
def update
|
||||
service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
|
||||
|
||||
if service.execute(issue)
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def issue
|
||||
@issue ||=
|
||||
IssuesFinder.new(current_user, project_id: project.id, state: 'all')
|
||||
.execute
|
||||
.where(iid: params[:id])
|
||||
.first!
|
||||
end
|
||||
|
||||
def authorize_read_issue!
|
||||
return render_403 unless can?(current_user, :read_issue, project)
|
||||
end
|
||||
|
||||
def authorize_update_issue!
|
||||
return render_403 unless can?(current_user, :update_issue, issue)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.merge(id: params[:list_id])
|
||||
end
|
||||
|
||||
def move_params
|
||||
params.permit(:id, :from_list_id, :to_list_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,81 @@
|
|||
module Projects
|
||||
module Boards
|
||||
class ListsController < Boards::ApplicationController
|
||||
before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
|
||||
before_action :authorize_read_list!, only: [:index]
|
||||
|
||||
def index
|
||||
render json: serialize_as_json(project.board.lists)
|
||||
end
|
||||
|
||||
def create
|
||||
list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute
|
||||
|
||||
if list.valid?
|
||||
render json: serialize_as_json(list)
|
||||
else
|
||||
render json: list.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
list = project.board.lists.movable.find(params[:id])
|
||||
service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
|
||||
|
||||
if service.execute(list)
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
list = project.board.lists.destroyable.find(params[:id])
|
||||
service = ::Boards::Lists::DestroyService.new(project, current_user, params)
|
||||
|
||||
if service.execute(list)
|
||||
head :ok
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
service = ::Boards::Lists::GenerateService.new(project, current_user)
|
||||
|
||||
if service.execute
|
||||
render json: serialize_as_json(project.board.lists.movable)
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_admin_list!
|
||||
return render_403 unless can?(current_user, :admin_list, project)
|
||||
end
|
||||
|
||||
def authorize_read_list!
|
||||
return render_403 unless can?(current_user, :read_list, project)
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:list).permit(:label_id)
|
||||
end
|
||||
|
||||
def move_params
|
||||
params.require(:list).permit(:position)
|
||||
end
|
||||
|
||||
def serialize_as_json(resource)
|
||||
resource.as_json(
|
||||
only: [:id, :list_type, :position],
|
||||
methods: [:title],
|
||||
include: {
|
||||
label: { only: [:id, :title, :description, :color, :priority] }
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
class Projects::BoardsController < Projects::ApplicationController
|
||||
respond_to :html
|
||||
|
||||
before_action :authorize_read_board!, only: [:show]
|
||||
|
||||
def show
|
||||
::Boards::CreateService.new(project, current_user).execute
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_read_board!
|
||||
return access_denied! unless can?(current_user, :read_board, project)
|
||||
end
|
||||
end
|
|
@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def commit
|
||||
@commit ||= @project.commit(params[:id])
|
||||
@noteable = @commit ||= @project.commit(params[:id])
|
||||
end
|
||||
|
||||
def pipelines
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
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
|
|||
@ci = true
|
||||
elsif auth_result.type == :oauth && !download_request?
|
||||
# Not allowed
|
||||
elsif auth_result.type == :missing_personal_token
|
||||
render_missing_personal_token
|
||||
return # Render above denied access, nothing left to do
|
||||
else
|
||||
@user = auth_result.user
|
||||
end
|
||||
|
@ -91,6 +94,13 @@ class Projects::GitHttpClientController < Projects::ApplicationController
|
|||
[nil, nil]
|
||||
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
|
||||
_, suffix = project_id_with_suffix
|
||||
if suffix == '.wiki.git'
|
||||
|
|
|
@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
protected
|
||||
|
||||
def issue
|
||||
@issue ||= begin
|
||||
@project.issues.find_by!(iid: params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_old
|
||||
end
|
||||
@noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
|
||||
end
|
||||
alias_method :subscribable_resource, :issue
|
||||
alias_method :issuable, :issue
|
||||
|
@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
|
||||
if issue
|
||||
redirect_to issue_path(issue)
|
||||
return
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound.new
|
||||
end
|
||||
|
|
|
@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
|
||||
before_action :module_enabled
|
||||
before_action :merge_request, only: [
|
||||
:edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
|
||||
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
|
||||
:edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
|
||||
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
|
||||
]
|
||||
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
|
||||
before_action :define_show_vars, only: [:show, :diffs, :commits, :builds]
|
||||
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
|
||||
before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
|
||||
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
|
||||
before_action :define_commit_vars, only: [:diffs]
|
||||
before_action :define_diff_comment_vars, only: [:diffs]
|
||||
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds]
|
||||
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
|
||||
|
||||
# Allow read any merge_request
|
||||
before_action :authorize_read_merge_request!
|
||||
|
@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
# Allow modify merge_request
|
||||
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
|
||||
|
||||
before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
|
||||
|
||||
def index
|
||||
terms = params['issue_search']
|
||||
@merge_requests = merge_requests_collection
|
||||
|
@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def conflicts
|
||||
respond_to do |format|
|
||||
format.html { define_discussion_vars }
|
||||
|
||||
format.json do
|
||||
if @merge_request.conflicts_can_be_resolved_in_ui?
|
||||
render json: @merge_request.conflicts
|
||||
elsif @merge_request.can_be_merged?
|
||||
render json: {
|
||||
message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
|
||||
type: 'error'
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
|
||||
type: 'error'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_conflicts
|
||||
return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
|
||||
|
||||
if @merge_request.can_be_merged?
|
||||
render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
|
||||
|
||||
flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
|
||||
|
||||
render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
|
||||
rescue Gitlab::Conflict::File::MissingResolution => e
|
||||
render status: :bad_request, json: { message: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
def builds
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
|
@ -141,7 +184,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def pipelines
|
||||
@pipelines = @merge_request.all_pipelines
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
define_discussion_vars
|
||||
|
||||
render 'show'
|
||||
end
|
||||
format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } }
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
apply_diff_view_cookie!
|
||||
|
||||
build_merge_request
|
||||
@noteable = @merge_request
|
||||
|
||||
|
@ -158,7 +216,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
@base_commit = @merge_request.diff_base_commit
|
||||
@diffs = @merge_request.diffs(diff_options) if @merge_request.compare
|
||||
@diff_notes_disabled = true
|
||||
|
||||
@pipeline = @merge_request.pipeline
|
||||
@statuses = @pipeline.statuses.relevant if @pipeline
|
||||
|
||||
|
@ -324,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
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
|
||||
alias_method :subscribable_resource, :merge_request
|
||||
alias_method :issuable, :merge_request
|
||||
|
@ -338,6 +395,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
|
||||
end
|
||||
|
||||
def authorize_can_resolve_conflicts!
|
||||
return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
|
||||
end
|
||||
|
||||
def module_enabled
|
||||
return render_404 unless @project.merge_requests_enabled
|
||||
end
|
||||
|
@ -374,12 +435,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
# :show, :diff, :commits, :builds. but not when request the data through AJAX
|
||||
def define_discussion_vars
|
||||
# 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.
|
||||
inc_author_project_award_emoji.
|
||||
fresh.
|
||||
discussions
|
||||
@discussions = @merge_request.discussions
|
||||
|
||||
preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
|
||||
|
||||
|
@ -412,8 +470,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
noteable_id: @merge_request.id
|
||||
}
|
||||
|
||||
@use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
|
||||
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
|
||||
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
|
||||
@grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
|
||||
|
||||
Banzai::NoteRenderer.render(
|
||||
@grouped_diff_discussions.values.flat_map(&:notes),
|
||||
|
|
|
@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
before_action :authorize_read_note!
|
||||
before_action :authorize_create_note!, only: [:create]
|
||||
before_action :authorize_admin_note!, only: [:update, :destroy]
|
||||
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
|
||||
before_action :find_current_user_notes, only: [:index]
|
||||
|
||||
def index
|
||||
|
@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
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
|
||||
|
||||
def note
|
||||
|
@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
id: note.id,
|
||||
name: note.name
|
||||
}
|
||||
elsif note.valid?
|
||||
elsif note.persisted?
|
||||
Banzai::NoteRenderer.render([note], @project, current_user)
|
||||
|
||||
attrs = {
|
||||
|
@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
}
|
||||
|
||||
if note.diff_note?
|
||||
discussion = Discussion.new([note])
|
||||
discussion = note.to_discussion
|
||||
|
||||
attrs.merge!(
|
||||
diff_discussion_html: diff_discussion_html(discussion),
|
||||
|
@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
return access_denied! unless can?(current_user, :admin_note, note)
|
||||
end
|
||||
|
||||
def authorize_resolve_note!
|
||||
return access_denied! unless can?(current_user, :resolve_note, note)
|
||||
end
|
||||
|
||||
def note_params
|
||||
params.require(:note).permit(
|
||||
:note, :noteable, :noteable_id, :noteable_type, :project_id,
|
||||
|
|
|
@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def autocomplete_sources
|
||||
note_type = params['type']
|
||||
note_id = params['type_id']
|
||||
noteable =
|
||||
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)
|
||||
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id)
|
||||
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
|
||||
|
||||
@suggestions = {
|
||||
emojis: Gitlab::AwardEmoji.urls,
|
||||
|
@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
|
|||
milestones: autocomplete.milestones,
|
||||
mergerequests: autocomplete.merge_requests,
|
||||
labels: autocomplete.labels,
|
||||
members: participants
|
||||
members: participants,
|
||||
commands: autocomplete.commands(noteable, params[:type])
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
class MoveToProjectFinder
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def execute(from_project, search: nil, offset_id: nil)
|
||||
projects = @user.projects_where_can_admin_issues
|
||||
projects = projects.search(search) if search.present?
|
||||
projects = projects.excluding_project(from_project)
|
||||
|
||||
# to ask for Project#name_with_namespace
|
||||
projects.includes(namespace: :owner)
|
||||
end
|
||||
end
|
|
@ -17,7 +17,7 @@ class TodosFinder
|
|||
|
||||
attr_accessor :current_user, :params
|
||||
|
||||
def initialize(current_user, params)
|
||||
def initialize(current_user, params = {})
|
||||
@current_user = current_user
|
||||
@params = params
|
||||
end
|
||||
|
|
|
@ -32,6 +32,8 @@ module AppearancesHelper
|
|||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
|
@ -320,4 +320,8 @@ module ApplicationHelper
|
|||
capture(&block)
|
||||
end
|
||||
end
|
||||
|
||||
def page_class
|
||||
"issue-boards-page" if current_controller?(:boards)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,17 +11,14 @@ module BlobHelper
|
|||
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
|
||||
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
|
||||
|
||||
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,
|
||||
tree_join(ref, path),
|
||||
link_opts)
|
||||
options[:link_opts])
|
||||
|
||||
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' }
|
||||
|
|
|
@ -38,6 +38,10 @@ module CiStatusHelper
|
|||
'icon_status_pending'
|
||||
when 'running'
|
||||
'icon_status_running'
|
||||
when 'play'
|
||||
return icon('play fw')
|
||||
when 'created'
|
||||
'icon_status_pending'
|
||||
else
|
||||
'icon_status_cancel'
|
||||
end
|
||||
|
@ -48,13 +52,13 @@ module CiStatusHelper
|
|||
def render_commit_status(commit, tooltip_placement: 'auto left')
|
||||
project = commit.project
|
||||
path = builds_namespace_project_commit_path(project.namespace, project, commit)
|
||||
render_status_with_link('commit', commit.status, path, tooltip_placement)
|
||||
render_status_with_link('commit', commit.status, path, tooltip_placement: tooltip_placement)
|
||||
end
|
||||
|
||||
def render_pipeline_status(pipeline, tooltip_placement: 'auto left')
|
||||
project = pipeline.project
|
||||
path = namespace_project_pipeline_path(project.namespace, project, pipeline)
|
||||
render_status_with_link('pipeline', pipeline.status, path, tooltip_placement)
|
||||
render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
|
||||
end
|
||||
|
||||
def no_runners_for_project?(project)
|
||||
|
@ -62,13 +66,17 @@ module CiStatusHelper
|
|||
Ci::Runner.shared.blank?
|
||||
end
|
||||
|
||||
private
|
||||
def render_status_with_link(type, status, path = nil, tooltip_placement: 'auto left', cssclass: '')
|
||||
klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}"
|
||||
title = "#{type.titleize}: #{ci_label_for_status(status)}"
|
||||
data = { toggle: 'tooltip', placement: tooltip_placement }
|
||||
|
||||
def render_status_with_link(type, status, path, tooltip_placement, cssclass: '')
|
||||
link_to ci_icon_for_status(status),
|
||||
path,
|
||||
class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}",
|
||||
title: "#{type.titleize}: #{ci_label_for_status(status)}",
|
||||
data: { toggle: 'tooltip', placement: tooltip_placement }
|
||||
if path
|
||||
link_to ci_icon_for_status(status), path,
|
||||
class: klass, title: title, data: data
|
||||
else
|
||||
content_tag :span, ci_icon_for_status(status),
|
||||
class: klass, title: title, data: data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -98,28 +98,31 @@ module CommitsHelper
|
|||
end
|
||||
|
||||
def link_to_browse_code(project, commit)
|
||||
if current_controller?(:projects, :commits)
|
||||
if @repo.blob_at(commit.id, @path)
|
||||
return link_to(
|
||||
"Browse File",
|
||||
namespace_project_blob_path(project.namespace, project,
|
||||
tree_join(commit.id, @path)),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
elsif @path.present?
|
||||
return link_to(
|
||||
"Browse Directory",
|
||||
namespace_project_tree_path(project.namespace, project,
|
||||
tree_join(commit.id, @path)),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
end
|
||||
if @path.blank?
|
||||
return link_to(
|
||||
"Browse Files",
|
||||
namespace_project_tree_path(project.namespace, project, commit),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
end
|
||||
|
||||
return unless current_controller?(:projects, :commits)
|
||||
|
||||
if @repo.blob_at(commit.id, @path)
|
||||
return link_to(
|
||||
"Browse File",
|
||||
namespace_project_blob_path(project.namespace, project,
|
||||
tree_join(commit.id, @path)),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
elsif @path.present?
|
||||
return link_to(
|
||||
"Browse Directory",
|
||||
namespace_project_tree_path(project.namespace, project,
|
||||
tree_join(commit.id, @path)),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
end
|
||||
link_to(
|
||||
"Browse Files",
|
||||
namespace_project_tree_path(project.namespace, project, commit),
|
||||
class: "btn btn-default"
|
||||
)
|
||||
end
|
||||
|
||||
def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
|
||||
|
|
|
@ -114,9 +114,17 @@ module IssuesHelper
|
|||
end
|
||||
|
||||
def award_user_list(awards, current_user)
|
||||
awards.map do |award|
|
||||
award.user == current_user ? 'me' : award.user.name
|
||||
end.join(', ')
|
||||
names = awards.map do |award|
|
||||
award.user == current_user ? 'You' : award.user.name
|
||||
end
|
||||
|
||||
# Take first 9 OR current user + first 9
|
||||
current_user_name = names.delete('You')
|
||||
names = names.first(9).insert(0, current_user_name).compact
|
||||
|
||||
names << "#{awards.size - names.size} more." if awards.size > names.size
|
||||
|
||||
names.to_sentence
|
||||
end
|
||||
|
||||
def award_active_class(awards, current_user)
|
||||
|
|
|
@ -24,6 +24,7 @@ module NavHelper
|
|||
current_path?('merge_requests#diffs') ||
|
||||
current_path?('merge_requests#commits') ||
|
||||
current_path?('merge_requests#builds') ||
|
||||
current_path?('merge_requests#conflicts') ||
|
||||
current_path?('issues#show')
|
||||
if cookies[:collapsed_gutter] == 'true'
|
||||
"page-gutter right-sidebar-collapsed"
|
||||
|
|
|
@ -49,7 +49,7 @@ module NotesHelper
|
|||
}
|
||||
|
||||
if use_legacy_diff_note
|
||||
discussion_id = LegacyDiffNote.build_discussion_id(
|
||||
discussion_id = LegacyDiffNote.discussion_id(
|
||||
@comments_target[:noteable_type],
|
||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||
line_code
|
||||
|
@ -60,7 +60,7 @@ module NotesHelper
|
|||
discussion_id: discussion_id
|
||||
)
|
||||
else
|
||||
discussion_id = DiffNote.build_discussion_id(
|
||||
discussion_id = DiffNote.discussion_id(
|
||||
@comments_target[:noteable_type],
|
||||
@comments_target[:noteable_id] || @comments_target[:commit_id],
|
||||
position
|
||||
|
@ -81,10 +81,8 @@ module NotesHelper
|
|||
|
||||
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',
|
||||
data: data, title: 'Add a reply'
|
||||
end
|
||||
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
|
||||
data: data, title: 'Add a reply'
|
||||
end
|
||||
|
||||
def preload_max_access_for_authors(notes, project)
|
||||
|
|
|
@ -6,6 +6,11 @@ module Emails
|
|||
mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id))
|
||||
end
|
||||
|
||||
def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id)
|
||||
setup_issue_mail(issue_id, recipient_id)
|
||||
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
|
||||
end
|
||||
|
||||
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
|
||||
setup_issue_mail(issue_id, recipient_id)
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue