Commit a315e602 authored by Zeger-Jan van de Weg's avatar Zeger-Jan van de Weg

Merge branch 'master' into zj-auto-devops-table

parents 78dad4cf fd54a467
...@@ -2,6 +2,24 @@ ...@@ -2,6 +2,24 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 9.5.3 (2017-09-03)
- [SECURITY] Filter additional secrets from Rails logs.
- [FIXED] Make username update fail if the namespace update fails. !13642
- [FIXED] Fix failure when issue is authored by a deleted user. !13807
- [FIXED] Reverts changes made to signin_enabled. !13956
- [FIXED] Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment.
- [FIXED] Fixed diff changes bar buttons from showing/hiding whilst scrolling.
- [FIXED] Fix events error importing GitLab projects.
- [FIXED] Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5.
- [FIXED] Fixed fly-out nav flashing in & out.
- [FIXED] Remove closing external issues by reference error.
- [FIXED] Re-allow appearances.description_html to be NULL.
- [CHANGED] Update and fix resolvable note icons for easier recognition.
- [OTHER] Eager load head pipeline projects for MRs index.
- [OTHER] Instrument MergeRequest#fetch_ref.
- [OTHER] Instrument MergeRequest#ensure_ref_fetched.
## 9.5.2 (2017-08-28) ## 9.5.2 (2017-08-28)
- [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays. - [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays.
......
...@@ -349,6 +349,8 @@ group :development, :test do ...@@ -349,6 +349,8 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2' gem 'activerecord_sane_schema_dumper', '0.2'
gem 'stackprof', '~> 0.2.10', require: false gem 'stackprof', '~> 0.2.10', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
end end
group :test do group :test do
......
...@@ -723,7 +723,7 @@ GEM ...@@ -723,7 +723,7 @@ GEM
retriable (1.4.1) retriable (1.4.1)
rinku (2.0.0) rinku (2.0.0)
rotp (2.1.2) rotp (2.1.2)
rouge (2.2.0) rouge (2.2.1)
rqrcode (0.7.0) rqrcode (0.7.0)
chunky_png chunky_png
rqrcode-rails3 (0.1.7) rqrcode-rails3 (0.1.7)
...@@ -833,6 +833,7 @@ GEM ...@@ -833,6 +833,7 @@ GEM
faraday (~> 0.9) faraday (~> 0.9)
jwt (~> 1.5) jwt (~> 1.5)
multi_json (~> 1.10) multi_json (~> 1.10)
simple_po_parser (1.1.2)
simplecov (0.14.1) simplecov (0.14.1)
docile (~> 1.1.0) docile (~> 1.1.0)
json (>= 1.8, < 3) json (>= 1.8, < 3)
...@@ -1145,6 +1146,7 @@ DEPENDENCIES ...@@ -1145,6 +1146,7 @@ DEPENDENCIES
sidekiq (~> 5.0) sidekiq (~> 5.0)
sidekiq-cron (~> 0.6.0) sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4) sidekiq-limit_fetch (~> 3.4)
simple_po_parser (~> 1.1.2)
simplecov (~> 0.14.0) simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1) slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1) spinach-rails (~> 0.2.1)
......
...@@ -2,17 +2,17 @@ ...@@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor'; import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() { window.Autosave = (function() {
function Autosave(field, key) { function Autosave(field, key, resource) {
this.field = field; this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) { if (key.join != null) {
key = key.join("/"); key = key.join('/');
} }
this.key = "autosave/" + key; this.key = 'autosave/' + key;
this.field.data("autosave", this); this.field.data('autosave', this);
this.restore(); this.restore();
this.field.on("input", (function(_this) { this.field.on('input', (function(_this) {
return function() { return function() {
return _this.save(); return _this.save();
}; };
...@@ -29,7 +29,17 @@ window.Autosave = (function() { ...@@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) { if ((text != null ? text.length : void 0) > 0) {
this.field.val(text); this.field.val(text);
} }
return this.field.trigger("input"); if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
}; };
Autosave.prototype.save = function() { Autosave.prototype.save = function() {
......
...@@ -109,6 +109,7 @@ class AwardsHandler { ...@@ -109,6 +109,7 @@ class AwardsHandler {
} }
$thumbsBtn.toggleClass('disabled', $userAuthored); $thumbsBtn.toggleClass('disabled', $userAuthored);
$thumbsBtn.prop('disabled', $userAuthored);
} }
// Create the emoji menu with the first category of emojis. // Create the emoji menu with the first category of emojis.
...@@ -234,14 +235,33 @@ class AwardsHandler { ...@@ -234,14 +235,33 @@ class AwardsHandler {
} }
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
if (gl.utils.isInIssuePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
$('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
awardName: emoji,
noteId: id,
},
});
document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
}
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined; return typeof callback === 'function' ? callback() : undefined;
}); });
$('.emoji-menu').removeClass('is-visible'); $('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active'); return $('.js-add-award.is-active').removeClass('is-active');
} }
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
...@@ -268,6 +288,14 @@ class AwardsHandler { ...@@ -268,6 +288,14 @@ class AwardsHandler {
} }
getVotesBlock() { getVotesBlock() {
if (gl.utils.isInIssuePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
return $el;
}
}
const currentBlock = $('.js-awards-block.current'); const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock; let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) { if (currentBlock.length === 0) {
......
...@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { ...@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) { if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]); $submitButton.trigger('click', [e]);
$submitButton.disable();
if (!gl.utils.isInIssuePage()) {
$submitButton.disable();
}
} }
}); });
......
...@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown'; ...@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':'); path = page.split(':');
shortcut_handler = null; shortcut_handler = null;
$('.js-gfm-input').each((i, el) => { $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete); const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), { gfm.setup($(el), {
...@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown'; ...@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable(); shortcut_handler = new ShortcutsIssuable();
new ZenMode(); new ZenMode();
initIssuableSidebar(); initIssuableSidebar();
initNotes();
break; break;
case 'dashboard:milestones:index': case 'dashboard:milestones:index':
new ProjectSelect(); new ProjectSelect();
......
...@@ -128,7 +128,7 @@ window.DropzoneInput = (function() { ...@@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any) // removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue. // and remove them from dropzone files queue.
$cancelButton.on('click', (e) => { $cancelButton.on('click', (e) => {
const target = e.target.closest('form').querySelector('.div-dropzone'); const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
...@@ -140,7 +140,7 @@ window.DropzoneInput = (function() { ...@@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again. // and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it. // addFile() adds file to dropzone files queue and upload it.
$retryLink.on('click', (e) => { $retryLink.on('click', (e) => {
const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
const failedFiles = dropzoneInstance.files; const failedFiles = dropzoneInstance.files;
e.preventDefault(); e.preventDefault();
......
...@@ -12,6 +12,7 @@ let sidebar; ...@@ -12,6 +12,7 @@ let sidebar;
export const mousePos = []; export const mousePos = [];
export const setSidebar = (el) => { sidebar = el; }; export const setSidebar = (el) => { sidebar = el; };
export const getOpenMenu = () => currentOpenMenu;
export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; }; export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
...@@ -141,6 +142,14 @@ export const documentMouseMove = (e) => { ...@@ -141,6 +142,14 @@ export const documentMouseMove = (e) => {
if (mousePos.length > 6) mousePos.shift(); if (mousePos.length > 6) mousePos.shift();
}; };
export const subItemsMouseLeave = (relatedTarget) => {
clearTimeout(timeoutId);
if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) {
hideMenu(currentOpenMenu);
}
};
export default () => { export default () => {
sidebar = document.querySelector('.nav-sidebar'); sidebar = document.querySelector('.nav-sidebar');
...@@ -162,10 +171,7 @@ export default () => { ...@@ -162,10 +171,7 @@ export default () => {
const subItems = el.querySelector('.sidebar-sub-level-items'); const subItems = el.querySelector('.sidebar-sub-level-items');
if (subItems) { if (subItems) {
subItems.addEventListener('mouseleave', () => { subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget));
clearTimeout(timeoutId);
hideMenu(currentOpenMenu);
});
} }
el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget)); el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
......
...@@ -42,7 +42,7 @@ class Issue { ...@@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() { initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.'; const issueFailMessage = 'Unable to update this issue at this time.';
return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => { return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => {
var $button, shouldSubmit, url; var $button, shouldSubmit, url;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
...@@ -66,12 +66,11 @@ class Issue { ...@@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter'); const projectIssuesCounter = $('.issue_counter');
if ('id' in data) { if ('id' in data) {
$(document).trigger('issuable:change');
const isClosed = $button.hasClass('btn-close'); const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed); isOpenBadge.toggleClass('hidden', isClosed);
$(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed); this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
...@@ -121,7 +120,7 @@ class Issue { ...@@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) { static submitNoteForm(form) {
var noteText; var noteText;
noteText = form.find("textarea.js-note-text").val(); noteText = form.find("textarea.js-note-text").val();
if (noteText.trim().length > 0) { if (noteText && noteText.trim().length > 0) {
return form.submit(); return form.submit();
} }
} }
......
...@@ -80,11 +80,11 @@ export default { ...@@ -80,11 +80,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -96,7 +96,7 @@ export default { ...@@ -96,7 +96,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -242,11 +242,11 @@ export default { ...@@ -242,11 +242,11 @@ export default {
:can-move="canMove" :can-move="canMove"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:issuable-templates="issuableTemplates" :issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs" :markdown-docs-path="markdownDocsPath"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl" :projects-autocomplete-path="projectsAutocompletePath"
/> />
<div v-else> <div v-else>
<title-component <title-component
......
...@@ -10,11 +10,11 @@ ...@@ -10,11 +10,11 @@
type: Object, type: Object,
required: true, required: true,
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -36,8 +36,8 @@ ...@@ -36,8 +36,8 @@
Description Description
</label> </label>
<markdown-field <markdown-field
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:markdown-docs="markdownDocs"> :markdown-docs-path="markdownDocsPath">
<textarea <textarea
id="issue-description" id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area" class="note-textarea js-gfm-input js-autosize markdown-area"
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
type: Object, type: Object,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
$moveDropdown.select2({ $moveDropdown.select2({
ajax: { ajax: {
url: this.projectsAutocompleteUrl, url: this.projectsAutocompletePath,
quietMillis: 125, quietMillis: 125,
data(term, page, context) { data(term, page, context) {
return { return {
......
...@@ -26,11 +26,11 @@ ...@@ -26,11 +26,11 @@
required: false, required: false,
default: () => [], default: () => [],
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -89,14 +89,14 @@ ...@@ -89,14 +89,14 @@
</div> </div>
<description-field <description-field
:form-state="formState" :form-state="formState"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:markdown-docs="markdownDocs" /> :markdown-docs-path="markdownDocsPath" />
<confidential-checkbox <confidential-checkbox
:form-state="formState" /> :form-state="formState" />
<project-move <project-move
v-if="canMove" v-if="canMove"
:form-state="formState" :form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" /> :projects-autocomplete-path="projectsAutocompletePath" />
<edit-actions <edit-actions
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" /> :can-destroy="canDestroy" />
......
...@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText, initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates, issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential, isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl, markdownPreviewPath: this.markdownPreviewPath,
markdownDocs: this.markdownDocs, markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath, projectPath: this.projectPath,
projectNamespace: this.projectNamespace, projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl, projectsAutocompletePath: this.projectsAutocompletePath,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
updatedByName: this.updatedByName, updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath, updatedByPath: this.updatedByPath,
......
...@@ -27,6 +27,13 @@ ...@@ -27,6 +27,13 @@
} }
}; };
w.gl.utils.isInIssuePage = () => {
const page = gl.utils.getPagePath(1);
const action = gl.utils.getPagePath(2);
return page === 'issues' && action === 'show';
};
w.gl.utils.ajaxGet = function(url) { w.gl.utils.ajaxGet = function(url) {
return $.ajax({ return $.ajax({
type: "GET", type: "GET",
...@@ -167,11 +174,12 @@ ...@@ -167,11 +174,12 @@
}; };
gl.utils.scrollToElement = function($el) { gl.utils.scrollToElement = function($el) {
var top = $el.offset().top; const top = $el.offset().top;
gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({ return $('body, html').animate({
scrollTop: top - (gl.mrTabsHeight) scrollTop: top - mrTabsHeight - headerHeight,
}, 200); }, 200);
}; };
......
...@@ -2,19 +2,20 @@ import _ from 'underscore'; ...@@ -2,19 +2,20 @@ import _ from 'underscore';
(() => { (() => {
/* /*
* TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints, * TODO: Make these methods more configurable (e.g. stringifyTime condensed or
* stringifyTime condensed or non-condensed, abbreviateTimelengths) * non-condensed, abbreviateTimelengths)
* */ * */
const utils = window.gl.utils = gl.utils || {}; const utils = window.gl.utils = gl.utils || {};
const prettyTime = utils.prettyTime = { const prettyTime = utils.prettyTime = {
/* /*
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero. * Seconds can be negative or positive, zero or non-zero. Can be configured for any day
* or week length.
*/ */
parseSeconds(seconds) { parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) {
const DAYS_PER_WEEK = 5; const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = 8; const HOURS_PER_DAY = hoursPerDay;
const MINUTES_PER_HOUR = 60; const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
......
This diff is collapsed.
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
import { SYSTEM_NOTE } from '../constants';
import issueNote from './issue_note.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteForm from './issue_note_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isReplying: false,
};
},
components: {
issueNote,
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteSignedOutWidget,
issueNoteEditedText,
issueNoteForm,
placeholderNote,
placeholderSystemNote,
},
mixins: [
autosave,
],
computed: {
...mapGetters([
'getIssueData',
]),
discussion() {
return this.note.notes[0];
},
author() {
return this.discussion.author;
},
canReply() {
return this.getIssueData.current_user.can_create_note;
},
newNotePath() {
return this.getIssueData.create_note_path;
},
lastUpdatedBy() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].author;
}
return null;
},
lastUpdatedAt() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].created_at;
}
return null;
},
},
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
]),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
}
return issueNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
},
showReplyForm() {
this.isReplying = true;
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel creating this comment?')) {
return;
}
}
this.resetAutoSave();
this.isReplying = false;
},
saveReply(noteText, form, callback) {
const replyData = {
endpoint: this.newNotePath,
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_id: this.discussion.noteable_id,
note: { note: noteText },
},
};
this.isReplying = false;
this.saveNote(replyData)
.then(() => {
this.resetAutoSave();
callback();
})
.catch((err) => {
this.removePlaceholderNotes();
this.isReplying = true;
this.$nextTick(() => {
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
Flash(msg, 'alert', $(this.$el));
this.$refs.noteForm.note = noteText;
callback(err);
});
});
},
},
mounted() {
if (this.isReplying) {
this.initAutoSave();
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<li class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="discussion">
<div class="discussion-header">
<issue-note-header
:author="author"
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
/>
<issue-note-edited-text
v-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
action-text="Last updated"
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<div
v-if="note.expanded"
class="discussion-body">
<div class="panel panel-default">
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<button
v-if="canReply && !isReplying"
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">Reply...</button>
<issue-note-form
v-if="isReplying"
save-button-title="Comment"
:discussion="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm"
/>
<issue-note-signed-out-widget v-if="!canReply" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue';
import issueNoteBody from './issue_note_body.vue';
import eventHub from '../event_hub';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isEditing: false,
isDeleting: false,
isRequesting: false,
};
},
components: {
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteBody,
},
computed: {
...mapGetters([
'targetNoteHash',
'getUserData',
]),
author() {
return this.note.author;
},
classNameBindings() {
return {
'is-editing': this.isEditing && !this.isRequesting,
'is-requesting being-posted': this.isRequesting,
'disabled-content': this.isDeleting,
target: this.targetNoteHash === this.noteAnchorId,
};
},
canReportAsAbuse() {
return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
},
},
methods: {
...mapActions([
'deleteNote',
'updateNote',
'scrollToNoteIfNeeded',
]),
editHandler() {
this.isEditing = true;
},
deleteHandler() {
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to delete this list?')) {
this.isDeleting = true;
this.deleteNote(this.note)
.then(() => {
this.isDeleting = false;
})
.catch(() => {
Flash('Something went wrong while deleting your note. Please try again.');
this.isDeleting = false;
});
}
},
formUpdateHandler(noteText, parentElement, callback) {
const data = {
endpoint: this.note.path,
note: {
target_type: 'issue',
target_id: this.note.noteable_id,
note: { note: noteText },
},
};
this.isRequesting = true;
this.oldContent = this.note.note_html;
this.note.note_html = noteText;
this.updateNote(data)
.then(() => {
this.isEditing = false;
this.isRequesting = false;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
callback();
})
.catch(() => {
this.isRequesting = false;
this.isEditing = true;
this.$nextTick(() => {
const msg = 'Something went wrong while editing your comment. Please try again.';
Flash(msg, 'alert', $(this.$el));
this.recoverNoteContent(noteText);
callback();
});
});
},
formCancelHandler(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel editing this comment?')) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
this.note.note_html = this.oldContent;
this.oldContent = null;
}
this.isEditing = false;
},
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
this.scrollToNoteIfNeeded($(this.$el));
}
});
},
};
</script>
<template>
<li
class="note timeline-entry"
:id="noteAnchorId"
:class="classNameBindings"
:data-award-url="note.toggle_award_path">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="author"
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
/>
<issue-note-actions
:author-id="author.id"
:note-id="note.id"
:access-level="note.human_access"
:can-edit="note.current_user.can_edit"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
/>
</div>
<issue-note-body
:note="note"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
@handleFormUpdate="formUpdateHandler"
@cancelFormEdition="formCancelHandler"
ref="noteBody"
/>
</div>
</div>
</li>
</template>
<script>
import { mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
name: 'issueNoteActions',
props: {
authorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
accessLevel: {
type: String,
required: false,
default: '',
},
reportAbusePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
canDelete: {
type: Boolean,
required: true,
},
canReportAsAbuse: {
type: Boolean,
required: true,
},
},
directives: {
tooltip,
},
components: {
loadingIcon,
},
computed: {
...mapGetters([
'getUserDataByProp',
]),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
canAddAwardEmoji() {
return this.currentUserId;
},
isAuthoredByCurrentUser() {
return this.authorId === this.currentUserId;
},
currentUserId() {
return this.getUserDataByProp('id');
},
},
methods: {
onEdit() {
this.$emit('handleEdit');
},
onDelete() {
this.$emit('handleDelete');
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
},
};
</script>
<template>
<div class="note-actions">
<span
v-if="accessLevel"
class="note-role">{{accessLevel}}</span>
<div
v-if="canAddAwardEmoji"
class="note-actions-item">
<a
v-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
data-position="right"
data-placement="bottom"
data-container="body"
href="#"
title="Add reaction">
<loading-icon :inline="true" />
<span
v-html="emojiSmiling"
class="link-highlight award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="link-highlight award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="link-highlight award-control-icon-super-positive">
</span>
</a>
</div>
<div
v-if="canEdit"
class="note-actions-item">
<button
@click="onEdit"
v-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom">
<span
v-html="editSvg"
class="link-highlight"></span>
</button>
</div>
<div
v-if="shouldShowActionsDropdown"
class="dropdown more-actions note-actions-item">
<button
v-tooltip
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
<span
class="icon"
v-html="ellipsisSvg"></span>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
<a :href="reportAbusePath">
Report as abuse
</a>
</li>
<li v-if="canEdit">
<button
@click.prevent="onDelete"
class="btn btn-transparent js-note-delete js-note-delete"
type="button">
<span class="text-danger">
Delete comment
</span>
</button>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: 'issueNoteAttachment',
props: {
attachment: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="note-attachment">
<a
v-if="attachment.image"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<img
:src="attachment.url"
class="note-image-attach" />
</a>
<div class="attachment">
<a
v-if="attachment.url"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<i
class="fa fa-paperclip"
aria-hidden="true"></i>
{{attachment.filename}}
</a>
</div>
</div>
</template>
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
awards: {
type: Array,
required: true,
},
toggleAwardPath: {
type: String,
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
},
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
]),
// `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
// [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
// This method will group emojis by their name as an Object. See below.
// {
// foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
// bar: [ { name: bar, user: user1 } ]
// }
// We need to do this otherwise we will render the same emoji over and over again.
groupedAwards() {
const awards = this.awards.reduce((acc, award) => {
if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
acc[award.name].push(award);
} else {
Object.assign(acc, { [award.name]: [award] });
}
return acc;
}, {});
const orderedAwards = {};
const { thumbsdown, thumbsup } = awards;
// Always show thumbsup and thumbsdown first
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
return Object.assign({}, orderedAwards, awards);
},
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
isLoggedIn() {
return this.getUserData.id;
},
},
methods: {
...mapActions([
'toggleAwardRequest',
]),
getAwardHTML(name) {
return glEmojiTag(name);
},
getAwardClassBindings(awardList, awardName) {
return {
active: this.hasReactionByCurrentUser(awardList),
disabled: !this.canInteractWithEmoji(awardList, awardName),
};
},
canInteractWithEmoji(awardList, awardName) {
let isAllowed = true;
const restrictedEmojis = ['thumbsup', 'thumbsdown'];
// Users can not add :+1: and :-1: to their own notes
if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
isAllowed = false;
}
return this.getUserData.id && isAllowed;
},
hasReactionByCurrentUser(awardList) {
return awardList.filter(award => award.user.id === this.getUserData.id).length;
},
awardTitle(awardsList) {
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (hasReactionByCurrentUser) {
awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the begining of the list so title will start with You.
if (hasReactionByCurrentUser) {
namesToShow.unshift('You');
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += ` and ${namesToShow.slice(-1)}`; // Append and text
} else { // We have only 2 users so join them with and.
title = namesToShow.join(' and ');
}
return title;
},
handleAward(awardName) {
if (!this.isLoggedIn) {
return;
}
let parsedName;
// 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
switch (awardName) {
case '100':
parsedName = 100;
break;
case '1234':
parsedName = 1234;
break;
default:
parsedName = awardName;
break;
}
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
awardName: parsedName,
};
this.toggleAwardRequest(data)
.catch(() => Flash('Something went wrong on our end.'));
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
},
};
</script>
<template>
<div class="note-awards">
<div class="awards js-awards-block">
<button
v-tooltip
v-for="(awardList, awardName, index) in groupedAwards"
:key="index"
:class="getAwardClassBindings(awardList, awardName)"
:title="awardTitle(awardList)"
@click="handleAward(awardName)"
class="btn award-control"
data-placement="bottom"
type="button">
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">
{{awardList.length}}
</span>
</button>
<div
v-if="isLoggedIn"
class="award-menu-holder">
<button
v-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
aria-label="Add reaction"
data-placement="bottom"
type="button">
<span
v-html="emojiSmiling"
class="award-control-icon award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="award-control-icon award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="award-control-icon award-control-icon-super-positive">
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>
</button>
</div>
</div>
</div>
</template>
<script>
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteAwardsList from './issue_note_awards_list.vue';
import issueNoteAttachment from './issue_note_attachment.vue';
import issueNoteForm from './issue_note_form.vue';
import TaskList from '../../task_list';
import autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
},
mixins: [
autosave,
],
components: {
issueNoteEditedText,
issueNoteAwardsList,
issueNoteAttachment,
issueNoteForm,
},
computed: {
noteBody() {
return this.note.note;
},
},
methods: {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
initTaskList() {
if (this.canEdit) {
this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
});
}
},
handleFormUpdate(note, parentElement, callback) {
this.$emit('handleFormUpdate', note, parentElement, callback);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelFormEdition', shouldConfirm, isDirty);
},
},
mounted() {
this.renderGFM();
this.initTaskList();
if (this.isEditing) {
this.initAutoSave();
}
},
updated() {
this.initTaskList();
this.renderGFM();
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<div
:class="{ 'js-task-list-container': canEdit }"
ref="note-body"
class="note-body">
<div
v-html="note.note_html"
class="note-text md"></div>
<issue-note-form
v-if="isEditing"
ref="noteForm"
@handleFormUpdate="handleFormUpdate"
@cancelFormEdition="formCancelHandler"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
/>
<textarea
v-if="canEdit"
v-model="note.note"
:data-update-url="note.path"
class="hidden js-task-list-field"></textarea>
<issue-note-edited-text
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
/>
<issue-note-awards-list
v-if="note.award_emoji.length"
:note-id="note.id"
:note-author-id="note.author.id"
:awards="note.award_emoji"
:toggle-award-path="note.toggle_award_path"
/>
<issue-note-attachment
v-if="note.attachment"
:attachment="note.attachment"
/>
</div>
</template>
<script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'editedNoteText',
props: {
actionText: {
type: String,
required: true,
},
editedAt: {
type: String,
required: true,
},
editedBy: {
type: Object,
required: false,
},
className: {
type: String,
required: false,
default: 'edited-text',
},
},
components: {
timeAgoTooltip,
},
};
</script>
<template>
<div :class="className">
{{actionText}}
<time-ago-tooltip
:time="editedAt"
tooltip-placement="bottom"
/>
<template v-if="editedBy">
by
<a
:href="editedBy.path"
class="js-vue-author author_link">
{{editedBy.name}}
</a>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
export default {
name: 'issueNoteForm',
props: {
noteBody: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: false,
},
saveButtonTitle: {
type: String,
required: false,
default: 'Save comment',
},
discussion: {
type: Object,
required: false,
default: () => ({}),
},
isEditing: {
type: Boolean,
required: true,
},
},
data() {
return {
note: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
};
},
components: {
confidentialIssue,
markdownField,
},
computed: {
...mapGetters([
'getDiscussionLastNote',
'getIssueDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
]),
noteHash() {
return `#note_${this.noteId}`;
},
markdownPreviewPath() {
return this.getIssueDataByProp('preview_note_path');
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
},
quickActionsDocsPath() {
return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
},
currentUserId() {
return this.getUserDataByProp('id');
},
isDisabled() {
return !this.note.length || this.isSubmitting;
},
isConfidentialIssue() {
return this.getIssueDataByProp('confidential');
},
},
methods: {
handleUpdate() {
this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
});
},
editMyLastNote() {
if (this.note === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
noteId: lastNoteInDiscussion.id,
});
}
}
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
},
},
mounted() {
this.$refs.textarea.focus();
},
watch: {
noteBody() {
if (this.note === this.noteBody) {
this.note = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
},
},
};
</script>
<template>
<div ref="editNoteForm" class="note-edit-form current-note-edit-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
<a
:href="noteHash"
target="_blank"
rel="noopener noreferrer">updated comment</a>
to ensure information is not lost.
</div>
<div class="flash-container timeline-content"></div>
<form
class="edit-note common-note-form js-quick-submit gfm-form">
<confidentialIssue v-if="isConfidentialIssue" />
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false">
<textarea
id="note_note"
name="note[note]"
class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
v-model="note"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="handleUpdate()"
@keydown.up="editMyLastNote()"
@keydown.esc="cancelHandler(true)">
</textarea>
</markdown-field>
<div class="note-form-actions clearfix">
<button
type="button"
@click="handleUpdate()"
:disabled="isDisabled"
class="js-vue-issue-save btn btn-save">
{{saveButtonTitle}}
</button>
<button
@click="cancelHandler()"
class="btn btn-cancel note-edit-cancel"
type="button">
Cancel
</button>
</div>
</form>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
props: {
author: {
type: Object,
required: true,
},
createdAt: {
type: String,
required: true,
},
actionText: {
type: String,
required: false,
default: '',
},
actionTextHtml: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: true,
},
includeToggle: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isExpanded: true,
};
},
components: {
timeAgoTooltip,
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
},
},
methods: {
...mapActions([
'setTargetNoteHash',
]),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink);
},
},
};
</script>
<template>
<div class="note-header-info">
<a :href="author.path">
<span class="note-header-author-name">
{{author.name}}
</span>
<span class="note-headline-light">
@{{author.username}}
</span>
</a>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
{{actionText}}
</template>
<span
v-if="actionTextHtml"
v-html="actionTextHtml"
class="system-note-message">
</span>
<a
:href="noteTimestampLink"
@click="updateTargetNoteHash"
class="note-timestamp">
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
/>
</a>
<i
class="fa fa-spinner fa-spin editing-spinner"
aria-label="Comment is being updated"
aria-hidden="true">
</i>
</span>
</span>
<div
v-if="includeToggle"
class="discussion-actions">
<button
@click="handleToggle"
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button">
<i
:class="toggleChevronClass"
class="fa"
aria-hidden="true">
</i>
Toggle discussion
</button>
</div>
</div>
</template>
import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg';
import iconCheck from 'icons/_icon_check_square_o.svg';
import iconClock from 'icons/_icon_clock_o.svg';
import iconCodeFork from 'icons/_icon_code_fork.svg';
import iconComment from 'icons/_icon_comment_o.svg';
import iconCommit from 'icons/_icon_commit.svg';
import iconEdit from 'icons/_icon_edit.svg';
import iconEye from 'icons/_icon_eye.svg';
import iconEyeSlash from 'icons/_icon_eye_slash.svg';
import iconMerge from 'icons/_icon_merge.svg';
import iconMerged from 'icons/_icon_merged.svg';
import iconRandom from 'icons/_icon_random.svg';
import iconClosed from 'icons/_icon_status_closed.svg';
import iconStatusOpen from 'icons/_icon_status_open.svg';
import iconStopwatch from 'icons/_icon_stopwatch.svg';
import iconTags from 'icons/_icon_tags.svg';
import iconUser from 'icons/_icon_user.svg';
export default {
icon_arrow_circle_o_right: iconArrowCircle,
icon_check_square_o: iconCheck,
icon_clock_o: iconClock,
icon_code_fork: iconCodeFork,
icon_comment_o: iconComment,
icon_commit: iconCommit,
icon_edit: iconEdit,
icon_eye: iconEye,
icon_eye_slash: iconEyeSlash,
icon_merge: iconMerge,
icon_merged: iconMerged,
icon_random: iconRandom,
icon_status_closed: iconClosed,
icon_status_open: iconStatusOpen,
icon_stopwatch: iconStopwatch,
icon_tags: iconTags,
icon_user: iconUser,
};
<script>
import { mapGetters } from 'vuex';
export default {
name: 'singInLinksNotes',
computed: {
...mapGetters([
'getNotesDataByProp',
]),
registerLink() {
return this.getNotesDataByProp('registerPath');
},
signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
},
};
</script>
<template>
<div class="disabled-comment text-center">
Please
<a :href="registerLink">register</a>
or
<a :href="signInLink">sign in</a>
to reply
</div>
</template>
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
import store from '../stores/';
import * as constants from '../constants';
import issueNote from './issue_note.vue';
import issueDiscussion from './issue_discussion.vue';
import issueSystemNote from './issue_system_note.vue';
import issueCommentForm from './issue_comment_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'issueNotesApp',
props: {
issueData: {
type: Object,
required: true,
},
notesData: {
type: Object,
required: true,
},
userData: {
type: Object,
required: false,
default: {},
},
},
store,
data() {
return {
isLoading: true,
};
},
components: {
issueNote,
issueDiscussion,
issueSystemNote,
issueCommentForm,
loadingIcon,
placeholderNote,
placeholderSystemNote,
},
computed: {
...mapGetters([
'notes',
'getNotesDataByProp',
]),
},
methods: {
...mapActions({
actionFetchNotes: 'fetchNotes',
poll: 'poll',
actionToggleAward: 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData',
setIssueData: 'setIssueData',
setUserData: 'setUserData',
setLastFetchedAt: 'setLastFetchedAt',
setTargetNoteHash: 'setTargetNoteHash',
}),
getComponentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
} else if (note.individual_note) {
return note.notes[0].system ? issueSystemNote : issueNote;
}
return issueDiscussion;
},
getComponentData(note) {
return note.individual_note ? note.notes[0] : note;
},
fetchNotes() {
return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
.then(() => this.initPolling())
.then(() => {
this.isLoading = false;
})
.then(() => this.$nextTick())
.then(() => this.checkLocationHash())
.catch(() => {
this.isLoading = false;
Flash('Something went wrong while fetching issue comments. Please try again.');
});
},
initPolling() {
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll();
},
checkLocationHash() {
const hash = gl.utils.getLocationHash();
const element = document.getElementById(hash);
if (hash && element) {
this.setTargetNoteHash(hash);
this.scrollToNoteIfNeeded($(element));
}
},
},
created() {
this.setNotesData(this.notesData);
this.setIssueData(this.issueData);
this.setUserData(this.userData);
},
mounted() {
this.fetchNotes();
const parentElement = this.$el.parentElement;
if (parentElement &&
parentElement.classList.contains('js-vue-notes-event')) {
parentElement.addEventListener('toggleAward', (event) => {
const { awardName, noteId } = event.detail;
this.actionToggleAward({ awardName, noteId });
});
}
},
};
</script>
<template>
<div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul
v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
v-for="note in notes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
/>
</ul>
<issue-comment-form />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'issuePlaceholderNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
userAvatarLink,
},
computed: {
...mapGetters([
'getUserData',
]),
},
};
</script>
<template>
<li class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
:img-size="40"
/>
</div>
<div
:class="{ discussion: !note.individual_note }"
class="timeline-content">
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
<span class="hidden-xs">{{getUserData.name}}</span>
<span class="note-headline-light">@{{getUserData.username}}</span>
</a>
</div>
</div>
<div class="note-body">
<div class="note-text">
<p>{{note.body}}</p>
</div>
</div>
</div>
</div>
</li>
</template>
<script>
export default {
name: 'placeholderSystemNote',
props: {
note: {
type: Object,
required: true,
},
},
};
</script>
<template>
<li class="note system-note timeline-entry being-posted fade-in-half">
<div class="timeline-entry-inner">
<div class="timeline-content">
<em>{{note.body}}</em>
</div>
</div>
</li>
</template>
<script>
import { mapGetters } from 'vuex';
import iconsMap from './issue_note_icons';
import issueNoteHeader from './issue_note_header.vue';
export default {
name: 'systemNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
issueNoteHeader,
},
computed: {
...mapGetters([
'targetNoteHash',
]),
noteAnchorId() {
return `note_${this.note.id}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
},
created() {
this.svg = iconsMap[this.note.system_note_icon_name];
},
};
</script>
<template>
<li
:id="noteAnchorId"
:class="{ target: isTargetNote }"
class="note system-note timeline-entry">
<div class="timeline-entry-inner">
<div
class="timeline-icon"
v-html="svg">
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="note.author"
:created-at="note.created_at"
:note-id="note.id"
:action-text-html="note.note_html" />
</div>
</div>
</div>
</li>
</template>
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment';
export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const NOTEABLE_TYPE = 'Issue';
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import issueNotesApp from './components/issue_notes_app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-vue-notes',
components: {
issueNotesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
return {
issueData: JSON.parse(notesDataset.issueData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: {
lastFetchedAt: notesDataset.lastFetchedAt,
discussionsPath: notesDataset.discussionsPath,
newSessionPath: notesDataset.newSessionPath,
registerPath: notesDataset.registerPath,
notesPath: notesDataset.notesPath,
markdownDocsPath: notesDataset.markdownDocsPath,
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
},
};
},
render(createElement) {
return createElement('issue-notes-app', {
props: {
issueData: this.issueData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
}));
/* globals Autosave */
import '../../autosave';
export default {
methods: {
initAutoSave() {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
},
resetAutoSave() {
this.autosave.reset();
},
setAutoSave() {
this.autosave.save();
},
},
};
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default {
fetchNotes(endpoint) {
return Vue.http.get(endpoint);
},
deleteNote(endpoint) {
return Vue.http.delete(endpoint);
},
replyToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
updateNote(endpoint, data) {
return Vue.http.put(endpoint, data, { emulateJSON: true });
},
createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
poll(data = {}) {
const { endpoint, lastFetchedAt } = data;
const options = {
headers: {
'X-Last-Fetched-At': lastFetchedAt,
},
};
return Vue.http.get(endpoint, options);
},
toggleAward(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
};
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import * as types from './mutation_types';
import * as utils from './utils';
import * as constants from '../constants';
import service from '../services/issue_notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const fetchNotes = ({ commit }, path) => service
.fetchNotes(path)
.then(res => res.json())
.then((res) => {
commit(types.SET_INITIAL_NOTES, res);
});
export const deleteNote = ({ commit }, note) => service
.deleteNote(note.path)
.then(() => {
commit(types.DELETE_NOTE, note);
});
export const updateNote = ({ commit }, { endpoint, note }) => service
.updateNote(endpoint, note)
.then(res => res.json())
.then((res) => {
commit(types.UPDATE_NOTE, res);
});
export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
.replyToDiscussion(endpoint, data)
.then(res => res.json())
.then((res) => {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
return res;
});
export const createNewNote = ({ commit }, { endpoint, data }) => service
.createNewNote(endpoint, data)
.then(res => res.json())
.then((res) => {
if (!res.errors) {
commit(types.ADD_NEW_NOTE, res);
}
return res;
});
export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES);
export const saveNote = ({ commit, dispatch }, noteData) => {
const { note } = noteData.data.note;
let placeholderText = note;
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
$('.notes-form .flash-container').hide(); // hide previous flash notification
if (hasQuickActions) {
placeholderText = utils.stripQuickActions(placeholderText);
}
if (placeholderText.length) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
noteBody: placeholderText,
replyId,
});
}
if (hasQuickActions) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
isSystemNote: true,
noteBody: utils.getQuickActionText(note),
replyId,
});
}
return dispatch(methodToDispatch, noteData)
.then((res) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
if (hasQuickActions && errors && Object.keys(errors).length) {
eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash('Commands applied', 'notice', $(noteData.flashContainer));
}
if (commandsChanges) {
if (commandsChanges.emoji_award) {
const votesBlock = $('.js-awards-block').eq(0);
loadAwardsHandler()
.then((awardsHandler) => {
awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
awardsHandler.scrollToAwards();
})
.catch(() => {
Flash(
'Something went wrong while adding your award. Please try again.',
null,
$(noteData.flashContainer),
);
});
}
if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
}
}
if (errors && errors.commands_only) {
Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
}
commit(types.REMOVE_PLACEHOLDER_NOTES);
return res;
});
};
const pollSuccessCallBack = (resp, commit, state, getters) => {
if (resp.notes && resp.notes.length) {
const { notesById } = getters;
resp.notes.forEach((note) => {
if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
} else {
commit(types.ADD_NEW_NOTE, note);
}
} else {
commit(types.ADD_NEW_NOTE, note);
}
});
}
commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
return resp;
};
export const poll = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
eTagPoll = new Poll({
resource: service,
method: 'poll',
data: requestData,
successCallback: resp => resp.json()
.then(data => pollSuccessCallBack(data, commit, state, getters)),
errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
service.poll(requestData);
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const fetchData = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
service.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash('Something went wrong while fetching latest comments.'));
};
export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
};
export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
const { endpoint, awardName } = data;
return service
.toggleAward(endpoint, { name: awardName })
.then(res => res.json())
.then(() => {
dispatch('toggleAward', data);
});
};
export const scrollToNoteIfNeeded = (context, el) => {
if (!gl.utils.isInViewport(el[0])) {
gl.utils.scrollToElement(el);
}
};
import _ from 'underscore';
export const notes = state => state.notes;
export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getIssueData = state => state.issueData;
export const getIssueDataByProp = state => prop => state.issueData[prop];
export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
export const notesById = state => state.notes.reduce((acc, note) => {
note.notes.every(n => Object.assign(acc, { [n.id]: n }));
return acc;
}, {});
const reverseNotes = array => array.slice(0).reverse();
const isLastNote = (note, state) => !note.system &&
state.userData && note.author &&
note.author.id === state.userData.id;
export const getCurrentUserLastNote = state => _.flatten(
reverseNotes(state.notes)
.map(note => reverseNotes(note.notes)),
).find(el => isLastNote(el, state));
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state));
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
notes: [],
targetNoteHash: null,
lastFetchedAt: null,
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
issueData: {},
},
actions,
getters,
mutations,
});
export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
export const SET_USER_DATA = 'SET_USER_DATA';
export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE';
import * as utils from './utils';
import * as types from './mutation_types';
import * as constants from '../constants';
export default {
[types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note;
const noteData = {
expanded: true,
id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE),
notes: [note],
reply_id: discussion_id,
};
state.notes.push(noteData);
},
[types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj) {
noteObj.notes.push(note);
}
},
[types.DELETE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
if (!noteObj.notes.length) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
const { notes } = state;
for (let i = notes.length - 1; i >= 0; i -= 1) {
const note = notes[i];
const children = note.notes;
if (children.length && !note.individual_note) { // remove placeholder from discussions
for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) {
children.splice(j, 1);
}
}
} else if (note.isPlaceholderNote) { // remove placeholders from state root
notes.splice(i, 1);
}
}
},
[types.SET_NOTES_DATA](state, data) {
Object.assign(state, { notesData: data });
},
[types.SET_ISSUE_DATA](state, data) {
Object.assign(state, { issueData: data });
},
[types.SET_USER_DATA](state, data) {
Object.assign(state, { userData: data });
},
[types.SET_INITIAL_NOTES](state, notesData) {
const notes = [];
notesData.forEach((note) => {
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn);
});
} else {
notes.push(note);
}
});
Object.assign(state, { notes });
},
[types.SET_LAST_FETCHED_AT](state, fetchedAt) {
Object.assign(state, { lastFetchedAt: fetchedAt });
},
[types.SET_TARGET_NOTE_HASH](state, hash) {
Object.assign(state, { targetNoteHash: hash });
},
[types.SHOW_PLACEHOLDER_NOTE](state, data) {
let notesArr = state.notes;
if (data.replyId) {
notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
}
notesArr.push({
individual_note: true,
isPlaceholderNote: true,
placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
notes: [
{
body: data.noteBody,
},
],
});
},
[types.TOGGLE_AWARD](state, data) {
const { awardName, note } = data;
const { id, name, username } = state.userData;
const hasEmojiAwardedByCurrentUser = note.award_emoji
.filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
if (hasEmojiAwardedByCurrentUser.length) {
// If current user has awarded this emoji, remove it.
note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
} else {
note.award_emoji.push({
name: awardName,
user: { id, name, username },
});
}
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
const discussion = utils.findNoteObjectById(state.notes, discussionId);
discussion.expanded = !discussion.expanded;
},
[types.UPDATE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
noteObj.notes.splice(0, 1, note);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
}
},
};
import AjaxCache from '~/lib/utils/ajax_cache';
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
export const getQuickActionText = (note) => {
let text = 'Applying command';
const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
const executedCommands = quickActions.filter((command) => {
const commandRegex = new RegExp(`/${command.name}`);
return commandRegex.test(note);
});
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
text = 'Applying multiple commands';
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
text = `Applying command to ${commandDescription}`;
}
}
return text;
};
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
...@@ -72,7 +72,7 @@ ...@@ -72,7 +72,7 @@
}; };
</script> </script>
<template> <template>
<div> <div class="ci-job-dropdown-container">
<button <button
v-tooltip v-tooltip
type="button" type="button"
......
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
}; };
</script> </script>
<template> <template>
<div> <div class="ci-job-component">
<a <a
v-tooltip v-tooltip
v-if="job.status.details_path" v-if="job.status.details_path"
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
}; };
</script> </script>
<template> <template>
<span> <span class="ci-job-name-component">
<ci-icon <ci-icon
:status="status" /> :status="status" />
......
...@@ -20,7 +20,7 @@ import './shortcuts_navigation'; ...@@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) { Mousetrap.bind('r', (function(_this) {
return function() { return function() {
_this.replyWithSelectedText(); _this.replyWithSelectedText(isMergeRequest);
return false; return false;
}; };
})(this)); })(this));
...@@ -38,9 +38,15 @@ import './shortcuts_navigation'; ...@@ -38,9 +38,15 @@ import './shortcuts_navigation';
} }
} }
ShortcutsIssuable.prototype.replyWithSelectedText = function() { ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
var quote, documentFragment, el, selected, separator; var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note'); let replyField;
if (isMergeRequest) {
replyField = $('.js-main-target-form #note_note');
} else {
replyField = $('.js-main-target-form .js-vue-comment-form');
}
documentFragment = window.gl.utils.getSelectedFragment(); documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) { if (!documentFragment) {
...@@ -57,6 +63,7 @@ import './shortcuts_navigation'; ...@@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) { quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n"; return ("> " + val).trim() + "\n";
}); });
// If replyField already has some content, add a newline before our quote // If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || ''; separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) { replyField.val(function(a, current) {
...@@ -64,7 +71,7 @@ import './shortcuts_navigation'; ...@@ -64,7 +71,7 @@ import './shortcuts_navigation';
}); });
// Trigger autosave // Trigger autosave
replyField.trigger('input'); replyField.trigger('input').trigger('change');
// Trigger autosize // Trigger autosize
var event = document.createEvent('Event'); var event = document.createEvent('Event');
......
...@@ -6,6 +6,7 @@ import timeTracker from './time_tracker'; ...@@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store'; import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator'; import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default { export default {
data() { data() {
...@@ -20,6 +21,9 @@ export default { ...@@ -20,6 +21,9 @@ export default {
methods: { methods: {
listenForQuickActions() { listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened); $(document).on('ajax:success', '.gfm-form', this.quickActionListened);
eventHub.$on('timeTrackingUpdated', (data) => {
this.quickActionListened(null, data);
});
}, },
quickActionListened(e, data) { quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate']; const subscribedCommands = ['spend_time', 'time_estimate'];
......
<script>
export default {
name: 'confidentialIssueWarning',
};
</script>
<template>
<div class="confidential-issue-warning">
<i
aria-hidden="true"
class="fa fa-eye-slash">
</i>
<span>
This is a confidential issue. Your comment will not be visible to the public.
</span>
</div>
</template>
...@@ -5,19 +5,30 @@ ...@@ -5,19 +5,30 @@
export default { export default {
props: { props: {
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
addSpacingClasses: {
type: Boolean,
required: false,
default: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
}, },
data() { data() {
return { return {
markdownPreview: '', markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
markdownPreviewLoading: false, markdownPreviewLoading: false,
previewMarkdown: false, previewMarkdown: false,
}; };
...@@ -26,35 +37,48 @@ ...@@ -26,35 +37,48 @@
markdownHeader, markdownHeader,
markdownToolbar, markdownToolbar,
}, },
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
},
methods: { methods: {
toggleMarkdownPreview() { toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown; this.previewMarkdown = !this.previewMarkdown;
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
const text = this.$slots.textarea[0].elm.value;
if (!this.previewMarkdown) { if (!this.previewMarkdown) {
this.markdownPreview = ''; this.markdownPreview = '';
} else { } else if (text) {
this.markdownPreviewLoading = true; this.markdownPreviewLoading = true;
this.$http.post( this.$http.post(this.markdownPreviewPath, { text })
this.markdownPreviewUrl, .then(resp => resp.json())
{ .then((data) => {
/* this.renderMarkdown(data);
Can't use `$refs` as the component is technically in the parent component })
so we access the VNode & then get the element .catch(() => new Flash('Error loading markdown preview'));
*/ } else {
text: this.$slots.textarea[0].elm.value, this.renderMarkdown();
}, }
) },
.then(resp => resp.json()) renderMarkdown(data = {}) {
.then((data) => { this.markdownPreviewLoading = false;
this.markdownPreviewLoading = false; this.markdownPreview = data.body || 'Nothing to preview.';
this.markdownPreview = data.body;
this.$nextTick(() => { if (data.references) {
$(this.$refs['markdown-preview']).renderGFM(); this.referencedCommands = data.references.commands;
}); this.referencedUsers = data.references.users;
})
.catch(() => new Flash('Error loading markdown preview'));
} }
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
}, },
}, },
mounted() { mounted() {
...@@ -74,7 +98,8 @@ ...@@ -74,7 +98,8 @@
<template> <template>
<div <div
class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" class="md-area js-vue-markdown-field"
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
ref="gl-form"> ref="gl-form">
<markdown-header <markdown-header
:preview-markdown="previewMarkdown" :preview-markdown="previewMarkdown"
...@@ -94,7 +119,9 @@ ...@@ -94,7 +119,9 @@
</i> </i>
</a> </a>
<markdown-toolbar <markdown-toolbar
:markdown-docs="markdownDocs" /> :markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
/>
</div> </div>
</div> </div>
<div <div
...@@ -108,5 +135,27 @@ ...@@ -108,5 +135,27 @@
Loading... Loading...
</span> </span>
</div> </div>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div
v-if="referencedCommands"
v-html="referencedCommands"
class="referenced-commands"></div>
<div
v-if="shouldShowReferencedUsers"
class="referenced-users">
<span>
<i
class="fa fa-exclamation-triangle"
aria-hidden="true">
</i>
You are about to add
<strong>
<span class="js-referenced-users-count">
{{referencedUsers.length}}
</span>
</strong> people to the discussion. Proceed with caution.
</span>
</div>
</template>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
quickActionsDocsPath: {
type: String,
required: false,
},
}, },
}; };
</script> </script>
...@@ -12,22 +16,77 @@ ...@@ -12,22 +16,77 @@
<template> <template>
<div class="comment-toolbar clearfix"> <div class="comment-toolbar clearfix">
<div class="toolbar-text"> <div class="toolbar-text">
<a <template v-if="!quickActionsDocsPath && markdownDocsPath">
:href="markdownDocs" <a
target="_blank" :href="markdownDocsPath"
tabindex="-1"> target="_blank"
Markdown is supported tabindex="-1">
</a> Markdown is supported
</a>
</template>
<template v-if="quickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
Markdown
</a>
and
<a
:href="quickActionsDocsPath"
target="_blank"
tabindex="-1">
quick actions
</a>
are supported
</template>
</div> </div>
<button <span class="uploading-container">
class="toolbar-button markdown-selector" <span class="uploading-progress-container hide">
type="button" <i
tabindex="-1"> class="fa fa-file-image-o toolbar-button-icon"
<i aria-hidden="true"></i>
class="fa fa-file-image-o toolbar-button-icon" <span class="attaching-file-message"></span>
aria-hidden="true"> <span class="uploading-progress">0%</span>
</i> <span class="uploading-spinner">
Attach a file <i
</button> class="fa fa-spinner fa-spin toolbar-button-icon"
aria-hidden="true"></i>
</span>
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
</span>
<span class="uploading-error-message"></span>
<button
class="retry-uploading-link"
type="button">
Try again
</button>
or
<button
class="attach-new-file markdown-selector"
type="button">
attach a new file
</button>
</span>
<button
class="markdown-selector button-attach-file"
tabindex="-1"
type="button">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
Attach a file
</button>
<button
class="btn btn-default btn-xs hide button-cancel-uploading-files"
type="button">
Cancel
</button>
</span>
</div> </div>
</template> </template>
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
.prepend-left-default { margin-left: $gl-padding; } .prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; } .prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; } .append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; } .append-right-10 { margin-right: 10px; }
.append-right-default { margin-right: $gl-padding; } .append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; } .append-right-20 { margin-right: 20px; }
......
...@@ -368,6 +368,10 @@ ...@@ -368,6 +368,10 @@
transform: translateY(0); transform: translateY(0);
} }
.comment-type-dropdown.open .dropdown-menu {
display: block;
}
.filtered-search-box-input-container { .filtered-search-box-input-container {
.dropdown-menu, .dropdown-menu,
.dropdown-menu-nav { .dropdown-menu-nav {
...@@ -729,6 +733,7 @@ ...@@ -729,6 +733,7 @@
#{$selector}.dropdown-menu, #{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav { #{$selector}.dropdown-menu-nav {
li { li {
display: block;
padding: 0 1px; padding: 0 1px;
&:hover { &:hover {
...@@ -748,9 +753,12 @@ ...@@ -748,9 +753,12 @@
} }
a, a,
button { button,
.menu-item {
border-radius: 0; border-radius: 0;
padding: 8px 16px; padding: 8px 16px;
text-align: left;
width: 100%;
// make sure the text color is not overriden // make sure the text color is not overriden
&.text-danger { &.text-danger {
......
...@@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd; ...@@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd;
/* /*
Pipeline Graph Pipeline Graph
*/ */
$stage-hover-bg: #eaf3fc; $stage-hover-bg: $gray-darker;
$stage-hover-border: #d1e7fc; $ci-action-icon-size: 22px;
$action-icon-color: #d6d6d6; $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
/* /*
Pipeline Schedules Pipeline Schedules
......
...@@ -8,15 +8,23 @@ header.navbar-gitlab-new { ...@@ -8,15 +8,23 @@ header.navbar-gitlab-new {
border-bottom: 0; border-bottom: 0;
.header-content { .header-content {
display: -webkit-flex;
display: flex;
padding-left: 0; padding-left: 0;
.title-container { .title-container {
display: -webkit-flex;
display: flex;
-webkit-align-items: stretch;
align-items: stretch; align-items: stretch;
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
padding-top: 0; padding-top: 0;
overflow: visible; overflow: visible;
} }
.title { .title {
display: -webkit-flex;
display: flex; display: flex;
padding-right: 0; padding-right: 0;
color: currentColor; color: currentColor;
...@@ -27,6 +35,7 @@ header.navbar-gitlab-new { ...@@ -27,6 +35,7 @@ header.navbar-gitlab-new {
} }
> a { > a {
display: -webkit-flex;
display: flex; display: flex;
align-items: center; align-items: center;
padding-right: $gl-padding; padding-right: $gl-padding;
...@@ -177,6 +186,7 @@ header.navbar-gitlab-new { ...@@ -177,6 +186,7 @@ header.navbar-gitlab-new {
} }
.navbar-sub-nav { .navbar-sub-nav {
display: -webkit-flex;
display: flex; display: flex;
margin-bottom: 0; margin-bottom: 0;
color: $indigo-200; color: $indigo-200;
......
...@@ -498,6 +498,7 @@ ...@@ -498,6 +498,7 @@
color: $gray-darkest; color: $gray-darkest;
display: block; display: block;
margin: 16px 0 0; margin: 16px 0 0;
font-size: 85%;
.author_link { .author_link {
color: $gray-darkest; color: $gray-darkest;
......
...@@ -250,6 +250,10 @@ ul.related-merge-requests > li { ...@@ -250,6 +250,10 @@ ul.related-merge-requests > li {
} }
} }
.discussion-reply-holder .note-edit-form {
display: block;
}
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.emoji-block .row { .emoji-block .row {
display: flex; display: flex;
......
...@@ -55,6 +55,10 @@ ...@@ -55,6 +55,10 @@
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
} }
.dropdown-menu.dropdown-menu-align-right {
margin-top: -2px;
}
} }
.form-horizontal { .form-horizontal {
...@@ -306,3 +310,7 @@ ...@@ -306,3 +310,7 @@
} }
} }
} }
.member-form-control {
@include new-style-dropdown;
}
...@@ -174,17 +174,6 @@ ...@@ -174,17 +174,6 @@
vertical-align: top; vertical-align: top;
} }
.mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item {
display: flex;
align-items: center;
.ci-status-text,
.ci-status-icon {
top: 0;
margin-right: 10px;
}
}
.normal { .normal {
line-height: 28px; line-height: 28px;
} }
...@@ -291,6 +280,7 @@ ...@@ -291,6 +280,7 @@
.dropdown-toggle { .dropdown-toggle {
.fa { .fa {
margin-left: 0;
color: inherit; color: inherit;
} }
} }
......
...@@ -20,10 +20,6 @@ ...@@ -20,10 +20,6 @@
} }
} }
.new-note {
display: none;
}
.new-note, .new-note,
.note-edit-form { .note-edit-form {
.note-form-actions { .note-form-actions {
...@@ -202,6 +198,10 @@ ...@@ -202,6 +198,10 @@
.discussion-reply-holder { .discussion-reply-holder {
background-color: $white-light; background-color: $white-light;
padding: 10px 16px; padding: 10px 16px;
&.is-replying {
padding-bottom: $gl-padding;
}
} }
} }
......
...@@ -100,6 +100,20 @@ ul.notes { ...@@ -100,6 +100,20 @@ ul.notes {
} }
} }
.editing-spinner {
display: none;
}
&.is-requesting {
.note-timestamp {
display: none;
}
.editing-spinner {
display: inline-block;
}
}
&.is-editing { &.is-editing {
.note-header, .note-header,
.note-text, .note-text,
...@@ -365,9 +379,7 @@ ul.notes { ...@@ -365,9 +379,7 @@ ul.notes {
} }
.discussion-header, .discussion-header,
.note-header { .note-header-info {
position: relative;
a { a {
color: inherit; color: inherit;
...@@ -402,6 +414,10 @@ ul.notes { ...@@ -402,6 +414,10 @@ ul.notes {
.note-header-info { .note-header-info {
min-width: 0; min-width: 0;
padding-bottom: 8px; padding-bottom: 8px;
&.discussion {
padding-bottom: 0;
}
} }
.system-note .note-header-info { .system-note .note-header-info {
...@@ -453,6 +469,8 @@ ul.notes { ...@@ -453,6 +469,8 @@ ul.notes {
} }
.note-actions { .note-actions {
@include new-style-dropdown;
align-self: flex-start; align-self: flex-start;
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
...@@ -488,22 +506,6 @@ ul.notes { ...@@ -488,22 +506,6 @@ ul.notes {
.more-actions-dropdown { .more-actions-dropdown {
width: 180px; width: 180px;
min-width: 180px; min-width: 180px;
margin-top: $gl-btn-padding;
li > a,
li > .btn {
color: $gl-text-color;
padding: $gl-btn-padding;
width: 100%;
text-align: left;
&:hover,
&:focus {
color: $gl-text-color;
background-color: $blue-25;
border-radius: $border-radius-default;
}
}
} }
.discussion-actions { .discussion-actions {
...@@ -814,10 +816,6 @@ ul.notes { ...@@ -814,10 +816,6 @@ ul.notes {
} }
} }
.discussion-notes .flash-container {
margin-bottom: 0;
}
// Merge request notes in diffs // Merge request notes in diffs
.diff-file { .diff-file {
// Diff is inline // Diff is inline
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
.btn.btn-retry:hover, .btn.btn-retry:hover,
.btn.btn-retry:focus { .btn.btn-retry:focus {
border-color: $gray-darkest; border-color: $dropdown-toggle-active-border-color;
background-color: $white-normal; background-color: $white-normal;
} }
...@@ -206,8 +206,8 @@ ...@@ -206,8 +206,8 @@
.stage-cell { .stage-cell {
.mini-pipeline-graph-dropdown-toggle svg { .mini-pipeline-graph-dropdown-toggle svg {
height: 22px; height: $ci-action-icon-size;
width: 22px; width: $ci-action-icon-size;
position: absolute; position: absolute;
top: -1px; top: -1px;
left: -1px; left: -1px;
...@@ -219,7 +219,7 @@ ...@@ -219,7 +219,7 @@
display: inline-block; display: inline-block;
position: relative; position: relative;
vertical-align: middle; vertical-align: middle;
height: 22px; height: $ci-action-icon-size;
margin: 3px 0; margin: 3px 0;
+ .stage-container { + .stage-container {
...@@ -308,7 +308,7 @@ ...@@ -308,7 +308,7 @@
a { a {
text-decoration: none; text-decoration: none;
color: $gl-text-color-secondary; color: $gl-text-color;
} }
svg { svg {
...@@ -432,7 +432,11 @@ ...@@ -432,7 +432,11 @@
width: 186px; width: 186px;
margin-bottom: 10px; margin-bottom: 10px;
white-space: normal; white-space: normal;
color: $gl-text-color-secondary;
// ensure .build-content has hover style when action-icon is hovered
.ci-job-dropdown-container:hover .build-content {
@extend .build-content:hover;
}
// Action Icons in big pipeline-graph nodes // Action Icons in big pipeline-graph nodes
.ci-action-icon-container .ci-action-icon-wrapper { .ci-action-icon-container .ci-action-icon-wrapper {
...@@ -445,11 +449,11 @@ ...@@ -445,11 +449,11 @@
&:hover { &:hover {
background-color: $stage-hover-bg; background-color: $stage-hover-bg;
border: 1px solid $stage-hover-bg; border: 1px solid $dropdown-toggle-active-border-color;
} }
svg { svg {
fill: $border-color; fill: $gl-text-color-secondary;
position: relative; position: relative;
left: -1px; left: -1px;
top: -1px; top: -1px;
...@@ -475,19 +479,10 @@ ...@@ -475,19 +479,10 @@
background-color: transparent; background-color: transparent;
border: none; border: none;
padding: 0; padding: 0;
color: $gl-text-color-secondary;
&:focus { &:focus {
outline: none; outline: none;
} }
&:hover {
color: $gl-text-color;
.dropdown-counter-badge {
color: $gl-text-color;
}
}
} }
.build-content { .build-content {
...@@ -502,8 +497,7 @@ ...@@ -502,8 +497,7 @@
a.build-content:hover, a.build-content:hover,
button.build-content:hover { button.build-content:hover {
background-color: $stage-hover-bg; background-color: $stage-hover-bg;
border: 1px solid $stage-hover-border; border: 1px solid $dropdown-toggle-active-border-color;
color: $gl-text-color;
} }
...@@ -564,7 +558,6 @@ ...@@ -564,7 +558,6 @@
// Triggers the dropdown in the big pipeline graph // Triggers the dropdown in the big pipeline graph
.dropdown-counter-badge { .dropdown-counter-badge {
color: $border-color;
font-weight: 100; font-weight: 100;
font-size: 15px; font-size: 15px;
position: absolute; position: absolute;
...@@ -606,8 +599,8 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -606,8 +599,8 @@ button.mini-pipeline-graph-dropdown-toggle {
background-color: $white-light; background-color: $white-light;
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
width: 22px; width: $ci-action-icon-size;
height: 22px; height: $ci-action-icon-size;
margin: 0; margin: 0;
padding: 0; padding: 0;
transition: all 0.2s linear; transition: all 0.2s linear;
...@@ -669,105 +662,119 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -669,105 +662,119 @@ button.mini-pipeline-graph-dropdown-toggle {
} }
} }
@include new-style-dropdown('.big-pipeline-graph-dropdown-menu');
@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu');
// dropdown content for big and mini pipeline // dropdown content for big and mini pipeline
.big-pipeline-graph-dropdown-menu, .big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu { .mini-pipeline-graph-dropdown-menu {
width: 195px; width: 195px;
max-width: 195px; max-width: 195px;
li {
padding: 2px 3px;
}
.scrollable-menu { .scrollable-menu {
padding: 0; padding: 0;
max-height: 245px; max-height: 245px;
overflow: auto; overflow: auto;
} }
// Action icon on the right li {
a.ci-action-icon-wrapper { position: relative;
color: $action-icon-color;
border: 1px solid $action-icon-color;
border-radius: 20px;
width: 22px;
height: 22px;
padding: 2px 0 0 5px;
cursor: pointer;
float: right;
margin: -26px 9px 0 0;
font-size: 12px;
background-color: $white-light;
&:hover, // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
&:focus { &:hover > .mini-pipeline-graph-dropdown-item,
background-color: $stage-hover-bg; &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
border: 1px solid transparent; @extend .mini-pipeline-graph-dropdown-item:hover;
} }
svg { // Action icon on the right
width: 22px; a.ci-action-icon-wrapper {
height: 22px; border-radius: 50%;
left: -6px; border: 1px solid $border-color;
position: relative; width: $ci-action-icon-size;
top: -3px; height: $ci-action-icon-size;
fill: $action-icon-color; padding: 2px 0 0 5px;
} font-size: 12px;
background-color: $white-light;
position: absolute;
top: 50%;
right: $gl-padding;
margin-top: -#{$ci-action-icon-size / 2};
&:hover svg, &:hover,
&:focus svg { &:focus {
fill: $gl-text-color; background-color: $stage-hover-bg;
} border: 1px solid $dropdown-toggle-active-border-color;
} }
// link to the build svg {
.mini-pipeline-graph-dropdown-item { fill: $gl-text-color-secondary;
padding: 3px 7px 4px; width: $ci-action-icon-size;
clear: both; height: $ci-action-icon-size;
font-weight: $gl-font-weight-normal; left: -6px;
line-height: 1.428571429; position: relative;
white-space: nowrap; top: -3px;
margin: 0 5px; }
border-radius: 3px;
// build name &:hover svg,
.ci-build-text, &:focus svg {
.ci-status-text { fill: $gl-text-color;
font-weight: 200; }
overflow: hidden; }
// link to the build
.mini-pipeline-graph-dropdown-item {
padding: 3px 7px 4px;
align-items: center;
clear: both;
display: flex;
font-weight: normal;
line-height: $line-height-base;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; border-radius: 3px;
max-width: 70%;
color: $gl-text-color-secondary;
margin-left: 2px;
display: inline-block;
top: 1px;
vertical-align: text-bottom;
position: relative;
@media (max-width: $screen-xs-max) { .ci-job-name-component {
max-width: 60%; align-items: center;
display: flex;
flex: 1;
} }
}
// status icon on the left // build name
.ci-status-icon { .ci-build-text,
top: 3px; .ci-status-text {
position: relative; font-weight: 200;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 70%;
margin-left: 2px;
display: inline-block;
> svg { @media (max-width: $screen-xs-max) {
overflow: visible; max-width: 60%;
width: 18px; }
height: 18px;
} }
}
&:hover, .ci-status-icon {
&:focus { @extend .append-right-8;
outline: none;
text-decoration: none; position: relative;
color: $gl-text-color;
background-color: $stage-hover-bg; > svg {
width: $pipeline-dropdown-status-icon-size;
height: $pipeline-dropdown-status-icon-size;
margin: 3px 0;
position: relative;
overflow: visible;
display: block;
}
}
&:hover,
&:focus {
outline: none;
text-decoration: none;
background-color: $stage-hover-bg;
}
} }
} }
} }
...@@ -776,16 +783,9 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -776,16 +783,9 @@ button.mini-pipeline-graph-dropdown-toggle {
.big-pipeline-graph-dropdown-menu { .big-pipeline-graph-dropdown-menu {
width: 195px; width: 195px;
min-width: 195px; min-width: 195px;
left: auto; left: 100%;
right: -195px; top: -10px;
top: -4px;
box-shadow: 0 1px 5px $black-transparent; box-shadow: 0 1px 5px $black-transparent;
.mini-pipeline-graph-dropdown-item {
.ci-status-icon {
top: -1px;
}
}
} }
/** /**
...@@ -806,15 +806,14 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -806,15 +806,14 @@ button.mini-pipeline-graph-dropdown-toggle {
} }
&::before { &::before {
left: -5px; left: -6px;
margin-top: -6px; margin-top: 3px;
border-width: 7px 5px 7px 0; border-width: 7px 5px 7px 0;
border-right-color: $border-color; border-right-color: $border-color;
} }
&::after { &::after {
left: -4px; left: -5px;
margin-top: -9px;
border-width: 10px 7px 10px 0; border-width: 10px 7px 10px 0;
border-right-color: $white-light; border-right-color: $white-light;
} }
......
...@@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base ...@@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base
end end
def check_password_expiration def check_password_expiration
if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication? if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user?
return redirect_to new_profile_password_path return redirect_to new_profile_password_path
end end
end end
......
...@@ -3,6 +3,7 @@ module NotesActions ...@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create] before_action :note_project, only: [:create]
end end
...@@ -12,14 +13,18 @@ module NotesActions ...@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at } notes_json = { notes: [], last_fetched_at: current_fetched_at }
@notes = notes_finder.execute.inc_relations_for_view notes = notes_finder.execute
@notes = prepare_notes_for_rendering(@notes) .inc_relations_for_view
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
@notes.each do |note| notes = prepare_notes_for_rendering(notes)
next if note.cross_reference_not_visible_for?(current_user)
notes_json[:notes] << note_json(note) notes_json[:notes] =
end if noteable.discussions_rendered_on_frontend?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
end
render json: notes_json render json: notes_json
end end
...@@ -82,22 +87,27 @@ module NotesActions ...@@ -82,22 +87,27 @@ module NotesActions
} }
if note.persisted? if note.persisted?
attrs.merge!( attrs[:valid] = true
valid: true,
id: note.id,
discussion_id: note.discussion_id(noteable),
html: note_html(note),
note: note.note
)
discussion = note.to_discussion(noteable) if noteable.nil? || noteable.discussions_rendered_on_frontend?
unless discussion.individual_note? attrs.merge!(note_serializer.represent(note))
else
attrs.merge!( attrs.merge!(
discussion_resolvable: discussion.resolvable?, id: note.id,
discussion_id: note.discussion_id(noteable),
diff_discussion_html: diff_discussion_html(discussion), html: note_html(note),
discussion_html: discussion_html(discussion) note: note.note
) )
discussion = note.to_discussion(noteable)
unless discussion.individual_note?
attrs.merge!(
discussion_resolvable: discussion.resolvable?,
diff_discussion_html: diff_discussion_html(discussion),
discussion_html: discussion_html(discussion)
)
end
end end
else else
attrs.merge!( attrs.merge!(
...@@ -168,6 +178,10 @@ module NotesActions ...@@ -168,6 +178,10 @@ module NotesActions
) )
end end
def set_polling_interval_header
Gitlab::PollingInterval.set_header(response, interval: 6_000)
end
def noteable def noteable
@noteable ||= notes_finder.target @noteable ||= notes_finder.target
end end
...@@ -180,6 +194,10 @@ module NotesActions ...@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params) @notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end end
def note_serializer
NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
end
def note_project def note_project
return @note_project if defined?(@note_project) return @note_project if defined?(@note_project)
return nil unless project return nil unless project
......
class PasswordsController < Devise::PasswordsController class PasswordsController < Devise::PasswordsController
include Gitlab::CurrentSettings
before_action :resource_from_email, only: [:create] before_action :resource_from_email, only: [:create]
before_action :check_password_authentication_available, only: [:create] before_action :prevent_ldap_reset, only: [:create]
before_action :throttle_reset, only: [:create] before_action :throttle_reset, only: [:create]
def edit def edit
...@@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController ...@@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email) self.resource = resource_class.find_by_email(email)
end end
def check_password_authentication_available def prevent_ldap_reset
return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?) return unless resource&.ldap_user?
redirect_to after_sending_reset_password_instructions_path_for(resource_name), redirect_to after_sending_reset_password_instructions_path_for(resource_name),
alert: "Password authentication is unavailable." alert: "Cannot reset password for LDAP user."
end end
def throttle_reset def throttle_reset
......
...@@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController ...@@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end end
def authorize_change_password! def authorize_change_password!
render_404 unless @user.allow_password_authentication? render_404 if @user.ldap_user?
end end
def user_params def user_params
......
...@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: IssueSerializer.new.represent(@issue) render json: serializer.represent(@issue)
end end
end end
end end
def discussions
notes = @issue.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
prepare_notes_for_rendering(notes)
discussions = Discussion.build_collection(notes, @issue)
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
end
def create def create
create_params = issue_params.merge(spammable_params).merge( create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
...@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do format.json do
if @issue.valid? if @issue.valid?
render json: IssueSerializer.new.represent(@issue) render json: serializer.represent(@issue)
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end end
...@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice redirect_to new_user_session_path, notice: notice
end end
def serializer
IssueSerializer.new(current_user: current_user, project: issue.project)
end
end end
...@@ -24,7 +24,6 @@ class IssuableFinder ...@@ -24,7 +24,6 @@ class IssuableFinder
include CreatedAtFilter include CreatedAtFilter
NONE = '0'.freeze NONE = '0'.freeze
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze
attr_accessor :current_user, :params attr_accessor :current_user, :params
...@@ -68,7 +67,7 @@ class IssuableFinder ...@@ -68,7 +67,7 @@ class IssuableFinder
# grouping and counting within that query. # grouping and counting within that query.
# #
def count_by_state def count_by_state
count_params = params.merge(state: nil, sort: nil, for_counting: true) count_params = params.merge(state: nil, sort: nil)
labels_count = label_names.any? ? label_names.count : 1 labels_count = label_names.any? ? label_names.count : 1
finder = self.class.new(current_user, count_params) finder = self.class.new(current_user, count_params)
counts = Hash.new(0) counts = Hash.new(0)
...@@ -91,16 +90,6 @@ class IssuableFinder ...@@ -91,16 +90,6 @@ class IssuableFinder
execute.find_by!(*params) execute.find_by!(*params)
end end
def state_counter_cache_key
cache_key(state_counter_cache_key_components)
end
def clear_caches!
state_counter_cache_key_components_permutations.each do |components|
Rails.cache.delete(cache_key(components))
end
end
def group def group
return @group if defined?(@group) return @group if defined?(@group)
...@@ -432,20 +421,4 @@ class IssuableFinder ...@@ -432,20 +421,4 @@ class IssuableFinder
def current_user_related? def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end end
def state_counter_cache_key_components
opts = params.with_indifferent_access
opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
opts.delete_if { |_, value| value.blank? }
['issuables_count', klass.to_ability_name, opts.sort]
end
def state_counter_cache_key_components_permutations
[state_counter_cache_key_components]
end
def cache_key(components)
Digest::SHA1.hexdigest(components.flatten.join('-'))
end
end end
...@@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder ...@@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder
project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
end end
# Anonymous users can't see any confidential issues. def user_cannot_see_confidential_issues?
#
# Users without access to see _all_ confidential issues (as in
# `user_can_see_all_confidential_issues?`) are more complicated, because they
# can see confidential issues where:
# 1. They are an assignee.
# 2. They are an author.
#
# That's fine for most cases, but if we're just counting, we need to cache
# effectively. If we cached this accurately, we'd have a cache key for every
# authenticated user without sufficient access to the project. Instead, when
# we are counting, we treat them as if they can't see any confidential issues.
#
# This does mean the counts may be wrong for those users, but avoids an
# explosion in cache keys.
def user_cannot_see_confidential_issues?(for_counting: false)
return false if user_can_see_all_confidential_issues? return false if user_can_see_all_confidential_issues?
current_user.blank? || for_counting || params[:for_counting] current_user.blank?
end
def state_counter_cache_key_components
extra_components = [
user_can_see_all_confidential_issues?,
user_cannot_see_confidential_issues?(for_counting: true)
]
super + extra_components
end
def state_counter_cache_key_components_permutations
# Ignore the last two, as we'll provide both options for them.
components = super.first[0..-3]
[
components + [false, true],
components + [true, false]
]
end end
def by_assignee(items) def by_assignee(items)
......
...@@ -303,7 +303,7 @@ module ApplicationHelper ...@@ -303,7 +303,7 @@ module ApplicationHelper
end end
def show_new_nav? def show_new_nav?
cookies["new_nav"] == "true" true
end end
def collapsed_sidebar? def collapsed_sidebar?
......
...@@ -84,6 +84,18 @@ module ApplicationSettingsHelper ...@@ -84,6 +84,18 @@ module ApplicationSettingsHelper
end end
end end
def key_restriction_options_for_select(type)
bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits|
["Must be at least #{bits} bits", bits]
end
[
['Are allowed', 0],
*bit_size_options,
['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE]
]
end
def repository_storages_options_for_select def repository_storages_options_for_select
options = Gitlab.config.repositories.storages.map do |name, storage| options = Gitlab.config.repositories.storages.map do |name, storage|
["#{name} - #{storage['path']}", name] ["#{name} - #{storage['path']}", name]
...@@ -117,6 +129,9 @@ module ApplicationSettingsHelper ...@@ -117,6 +129,9 @@ module ApplicationSettingsHelper
:domain_blacklist_enabled, :domain_blacklist_enabled,
:domain_blacklist_raw, :domain_blacklist_raw,
:domain_whitelist_raw, :domain_whitelist_raw,
:dsa_key_restriction,
:ecdsa_key_restriction,
:ed25519_key_restriction,
:email_author_in_body, :email_author_in_body,
:enabled_git_access_protocol, :enabled_git_access_protocol,
:gravatar_enabled, :gravatar_enabled,
...@@ -160,6 +175,7 @@ module ApplicationSettingsHelper ...@@ -160,6 +175,7 @@ module ApplicationSettingsHelper
:repository_storages, :repository_storages,
:require_two_factor_authentication, :require_two_factor_authentication,
:restricted_visibility_levels, :restricted_visibility_levels,
:rsa_key_restriction,
:send_user_confirmation_email, :send_user_confirmation_email,
:sentry_dsn, :sentry_dsn,
:sentry_enabled, :sentry_enabled,
......
module FormHelper module FormHelper
def form_errors(model) def form_errors(model, type: 'form')
return unless model.errors.any? return unless model.errors.any?
pluralized = 'error'.pluralize(model.errors.count) pluralized = 'error'.pluralize(model.errors.count)
headline = "The form contains the following #{pluralized}:" headline = "The #{type} contains the following #{pluralized}:"
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) << content_tag(:h4, headline) <<
......
...@@ -68,7 +68,7 @@ module GroupsHelper ...@@ -68,7 +68,7 @@ module GroupsHelper
def group_title_link(group, hidable: false) def group_title_link(group, hidable: false)
link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do
output = output =
if show_new_nav? if show_new_nav? && !Rails.env.test?
image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16) image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16)
else else
"" ""
......
...@@ -35,7 +35,7 @@ module IssuablesHelper ...@@ -35,7 +35,7 @@ module IssuablesHelper
def serialize_issuable(issuable) def serialize_issuable(issuable)
case issuable case issuable
when Issue when Issue
IssueSerializer.new.represent(issuable).to_json IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest when MergeRequest
MergeRequestSerializer MergeRequestSerializer
.new(current_user: current_user, project: issuable.project) .new(current_user: current_user, project: issuable.project)
...@@ -210,9 +210,9 @@ module IssuablesHelper ...@@ -210,9 +210,9 @@ module IssuablesHelper
canMove: current_user ? issuable.can_move?(current_user) : false, canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference, issuableRef: issuable.to_reference,
isConfidential: issuable.confidential, isConfidential: issuable.confidential,
markdownPreviewUrl: preview_markdown_path(@project), markdownPreviewPath: preview_markdown_path(@project),
markdownDocs: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), projectsAutocompletePath: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable), issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path, projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path, projectNamespace: ref_project.namespace.full_path,
...@@ -240,16 +240,9 @@ module IssuablesHelper ...@@ -240,16 +240,9 @@ module IssuablesHelper
} }
end end
def issuables_count_for_state(issuable_type, state, finder: nil) def issuables_count_for_state(issuable_type, state)
finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend
cache_key = finder.state_counter_cache_key finder.count_by_state[state]
@counts ||= {}
@counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
finder.count_by_state
end
@counts[cache_key][state]
end end
def close_issuable_url(issuable) def close_issuable_url(issuable)
......
...@@ -137,7 +137,7 @@ module IssuesHelper ...@@ -137,7 +137,7 @@ module IssuesHelper
end end
def awards_sort(awards) def awards_sort(awards)
awards.sort_by do |award, notes| awards.sort_by do |award, award_emojis|
if award == "thumbsup" if award == "thumbsup"
0 0
elsif award == "thumbsdown" elsif award == "thumbsdown"
......
...@@ -93,11 +93,13 @@ module NotesHelper ...@@ -93,11 +93,13 @@ module NotesHelper
end end
end end
def notes_url def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet) if @snippet.is_a?(PersonalSnippet)
snippet_notes_path(@snippet) snippet_notes_path(@snippet, params)
else else
project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore) params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
project_noteable_notes_path(@project, params)
end end
end end
......
...@@ -62,7 +62,7 @@ module ProjectsHelper ...@@ -62,7 +62,7 @@ module ProjectsHelper
project_link = link_to project_path(project), { class: "project-item-select-holder" } do project_link = link_to project_path(project), { class: "project-item-select-holder" } do
output = output =
if show_new_nav? if show_new_nav? && !Rails.env.test?
project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16) project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16)
else else
"" ""
......
...@@ -22,8 +22,14 @@ module SystemNoteHelper ...@@ -22,8 +22,14 @@ module SystemNoteHelper
'duplicate' => 'icon_clone' 'duplicate' => 'icon_clone'
}.freeze }.freeze
def system_note_icon_name(note)
ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
end
def icon_for_system_note(note) def icon_for_system_note(note)
icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] icon_name = system_note_icon_name(note)
custom_icon(icon_name) if icon_name custom_icon(icon_name) if icon_name
end end
extend self
end end
...@@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base
[\r\n] # any number of newline characters [\r\n] # any number of newline characters
}x }x
# Setting a key restriction to `-1` means that all keys of this type are
# forbidden.
FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize
...@@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base
presence: true, presence: true,
numericality: { greater_than_or_equal_to: 0 } numericality: { greater_than_or_equal_to: 0 }
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
validates :allowed_key_types, presence: true
validates_each :restricted_visibility_levels do |record, attr, value| validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level| value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level) unless Gitlab::VisibilityLevel.options.value?(level)
...@@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base
end end
before_validation :ensure_uuid! before_validation :ensure_uuid!
before_save :ensure_runners_registration_token before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token before_save :ensure_health_check_access_token
...@@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base
default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
disabled_oauth_sign_in_sources: [], disabled_oauth_sign_in_sources: [],
domain_whitelist: Settings.gitlab['domain_whitelist'], domain_whitelist: Settings.gitlab['domain_whitelist'],
dsa_key_restriction: 0,
ecdsa_key_restriction: 0,
ed25519_key_restriction: 0,
gravatar_enabled: Settings.gravatar['enabled'], gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil, help_page_text: nil,
help_page_hide_commercial_content: false, help_page_hide_commercial_content: false,
...@@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base
max_attachment_size: Settings.gitlab['max_attachment_size'], max_attachment_size: Settings.gitlab['max_attachment_size'],
password_authentication_enabled: Settings.gitlab['password_authentication_enabled'], password_authentication_enabled: Settings.gitlab['password_authentication_enabled'],
performance_bar_allowed_group_id: nil, performance_bar_allowed_group_id: nil,
rsa_key_restriction: 0,
plantuml_enabled: false, plantuml_enabled: false,
plantuml_url: nil, plantuml_url: nil,
project_export_enabled: true, project_export_enabled: true,
...@@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base
usage_ping_can_be_configured? && super usage_ping_can_be_configured? && super
end end
def allowed_key_types
SUPPORTED_KEY_TYPES.select do |type|
key_restriction_for(type) != FORBIDDEN_KEY_VALUE
end
end
def key_restriction_for(type)
attr_name = "#{type}_key_restriction"
has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
end
private private
def ensure_uuid! def ensure_uuid!
......
...@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base ...@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) } scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) } scope :upvotes, -> { where(name: UPVOTE_NAME) }
after_save :expire_etag_cache
after_destroy :expire_etag_cache
class << self class << self
def votes_for_collection(ids, type) def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count') select('name', 'awardable_id', 'COUNT(*) as count')
...@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base ...@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote? def upvote?
self.name == UPVOTE_NAME self.name == UPVOTE_NAME
end end
def expire_etag_cache
awardable.try(:expire_etag_cache)
end
end end
...@@ -251,6 +251,28 @@ class Commit ...@@ -251,6 +251,28 @@ class Commit
project.repository.next_branch("cherry-pick-#{short_id}", mild: true) project.repository.next_branch("cherry-pick-#{short_id}", mild: true)
end end
def cherry_pick_description(user)
message_body = "(cherry picked from commit #{sha})"
if merged_merge_request?(user)
commits_in_merge_request = merged_merge_request(user).commits
if commits_in_merge_request.present?
message_body << "\n"
commits_in_merge_request.reverse.each do |commit_in_merge|
message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}"
end
end
end
message_body
end
def cherry_pick_message(user)
%Q{#{message}\n\n#{cherry_pick_description(user)}}
end
def revert_description(user) def revert_description(user)
if merged_merge_request?(user) if merged_merge_request?(user)
"This reverts merge request #{merged_merge_request(user).to_reference}" "This reverts merge request #{merged_merge_request(user).to_reference}"
......
...@@ -24,6 +24,10 @@ module Noteable ...@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end end
def discussions_rendered_on_frontend?
false
end
def discussion_notes def discussion_notes
notes notes
end end
...@@ -38,7 +42,7 @@ module Noteable ...@@ -38,7 +42,7 @@ module Noteable
def grouped_diff_discussions(*args) def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes # Doesn't use `discussion_notes`, because this may include commit diff notes
# besides MR diff notes, that we do no want to display on the MR Changes tab. # besides MR diff notes, that we do not want to display on the MR Changes tab.
notes.inc_relations_for_view.grouped_diff_discussions(*args) notes.inc_relations_for_view.grouped_diff_discussions(*args)
end end
......
...@@ -81,6 +81,10 @@ class Discussion ...@@ -81,6 +81,10 @@ class Discussion
last_note.author last_note.author
end end
def updated?
last_updated_at != created_at
end
def id def id
first_note.discussion_id(context_noteable) first_note.discussion_id(context_noteable)
end end
......
...@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base ...@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base
end end
end end
def discussions_rendered_on_frontend?
true
end
def update_project_counter_caches? def update_project_counter_caches?
state_changed? || confidential_changed? state_changed? || confidential_changed?
end end
......
require 'digest/md5' require 'digest/md5'
class Key < ActiveRecord::Base class Key < ActiveRecord::Base
include Gitlab::CurrentSettings
include Sortable include Sortable
LAST_USED_AT_REFRESH_TIME = 1.day.to_i LAST_USED_AT_REFRESH_TIME = 1.day.to_i
...@@ -12,14 +13,18 @@ class Key < ActiveRecord::Base ...@@ -12,14 +13,18 @@ class Key < ActiveRecord::Base
validates :title, validates :title,
presence: true, presence: true,
length: { maximum: 255 } length: { maximum: 255 }
validates :key, validates :key,
presence: true, presence: true,
length: { maximum: 5000 }, length: { maximum: 5000 },
format: { with: /\A(ssh|ecdsa)-.*\Z/ } format: { with: /\A(ssh|ecdsa)-.*\Z/ }
validates :fingerprint, validates :fingerprint,
uniqueness: true, uniqueness: true,
presence: { message: 'cannot be generated' } presence: { message: 'cannot be generated' }
validate :key_meets_restrictions
delegate :name, :email, to: :user, prefix: true delegate :name, :email, to: :user, prefix: true
after_commit :add_to_shell, on: :create after_commit :add_to_shell, on: :create
...@@ -80,6 +85,10 @@ class Key < ActiveRecord::Base ...@@ -80,6 +85,10 @@ class Key < ActiveRecord::Base
SystemHooksService.new.execute_hooks_for(self, :destroy) SystemHooksService.new.execute_hooks_for(self, :destroy)
end end
def public_key
@public_key ||= Gitlab::SSHPublicKey.new(key)
end
private private
def generate_fingerprint def generate_fingerprint
...@@ -87,7 +96,27 @@ class Key < ActiveRecord::Base ...@@ -87,7 +96,27 @@ class Key < ActiveRecord::Base
return unless self.key.present? return unless self.key.present?
self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint self.fingerprint = public_key.fingerprint
end
def key_meets_restrictions
restriction = current_application_settings.key_restriction_for(public_key.type)
if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE
errors.add(:key, forbidden_key_type_message)
elsif public_key.bits < restriction
errors.add(:key, "must be at least #{restriction} bits")
end
end
def forbidden_key_type_message
allowed_types =
current_application_settings
.allowed_key_types
.map(&:upcase)
.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
"type is forbidden. Must be #{allowed_types}"
end end
def notify_user def notify_user
......
...@@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base
self.merge_requests_closing_issues.delete_all self.merge_requests_closing_issues.delete_all
closes_issues(current_user).each do |issue| closes_issues(current_user).each do |issue|
next if issue.is_a?(ExternalIssue)
self.merge_requests_closing_issues.create!(issue: issue) self.merge_requests_closing_issues.create!(issue: issue)
end end
end end
......
...@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base ...@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base
end end
end end
def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend?
key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: noteable_type.underscore,
target_id: noteable_id
)
Gitlab::EtagCaching::Store.new.touch(key)
end
private private
def keep_around_commit def keep_around_commit
...@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base ...@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base
def set_discussion_id def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self) self.discussion_id ||= discussion_class.discussion_id(self)
end end
def expire_etag_cache
return unless for_issue?
key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
noteable.project,
target_type: noteable_type.underscore,
target_id: noteable.id
)
Gitlab::EtagCaching::Store.new.touch(key)
end
end end
...@@ -226,6 +226,7 @@ class Project < ActiveRecord::Base ...@@ -226,6 +226,7 @@ class Project < ActiveRecord::Base
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 } validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_limit, on: :create validate :check_limit, on: :create
validate :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? }
validate :avatar_type, validate :avatar_type,
if: ->(project) { project.avatar.present? && project.avatar_changed? } if: ->(project) { project.avatar.present? && project.avatar_changed? }
validates :avatar, file_size: { maximum: 200.kilobytes.to_i } validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
...@@ -476,7 +477,7 @@ class Project < ActiveRecord::Base ...@@ -476,7 +477,7 @@ class Project < ActiveRecord::Base
end end
def repository_storage_path def repository_storage_path
Gitlab.config.repositories.storages[repository_storage]['path'] Gitlab.config.repositories.storages[repository_storage].try(:[], 'path')
end end
def team def team
...@@ -591,7 +592,7 @@ class Project < ActiveRecord::Base ...@@ -591,7 +592,7 @@ class Project < ActiveRecord::Base
end end
def valid_import_url? def valid_import_url?
valid? || errors.messages[:import_url].nil? valid?(:import_url) || errors.messages[:import_url].nil?
end end
def create_or_update_import_data(data: nil, credentials: nil) def create_or_update_import_data(data: nil, credentials: nil)
...@@ -1008,6 +1009,20 @@ class Project < ActiveRecord::Base ...@@ -1008,6 +1009,20 @@ class Project < ActiveRecord::Base
end end
end end
# Check if repository already exists on disk
def can_create_repository?
return false unless repository_storage_path
expires_full_path_cache # we need to clear cache to validate renames correctly
if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git")
errors.add(:base, 'There is already a repository with that name on disk')
return false
end
true
end
def create_repository(force: false) def create_repository(force: false)
# Forked import is handled asynchronously # Forked import is handled asynchronously
return if forked? && !force return if forked? && !force
...@@ -1498,6 +1513,10 @@ class Project < ActiveRecord::Base ...@@ -1498,6 +1513,10 @@ class Project < ActiveRecord::Base
self.storage_version.nil? self.storage_version.nil?
end end
def renamed?
persisted? && path_changed?
end
private private
def storage def storage
......
...@@ -908,7 +908,7 @@ class Repository ...@@ -908,7 +908,7 @@ class Repository
committer = user_to_committer(user) committer = user_to_committer(user)
create_commit(message: commit.message, create_commit(message: commit.cherry_pick_message(user),
author: { author: {
email: commit.author_email, email: commit.author_email,
name: commit.author_name, name: commit.author_name,
......
...@@ -603,7 +603,7 @@ class User < ActiveRecord::Base ...@@ -603,7 +603,7 @@ class User < ActiveRecord::Base
end end
def require_personal_access_token_creation_for_git_auth? def require_personal_access_token_creation_for_git_auth?
return false if allow_password_authentication? || ldap_user? return false if current_application_settings.password_authentication_enabled? || ldap_user?
PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
end end
......
...@@ -84,7 +84,7 @@ class WikiPage ...@@ -84,7 +84,7 @@ class WikiPage
# The formatted title of this page. # The formatted title of this page.
def title def title
if @attributes[:title] if @attributes[:title]
self.class.unhyphenize(@attributes[:title]) CGI.unescape_html(self.class.unhyphenize(@attributes[:title]))
else else
"" ""
end end
......
class AwardEmojiEntity < Grape::Entity
expose :name
expose :user, using: API::Entities::UserSafe
end
class DiscussionEntity < Grape::Entity
include RequestAwareEntity
expose :id, :reply_id
expose :expanded?, as: :expanded
expose :notes, using: NoteEntity
expose :individual_note?, as: :individual_note
end
class DiscussionSerializer < BaseSerializer
entity DiscussionEntity
end
...@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity ...@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity
expose :total_time_spent expose :total_time_spent
expose :human_time_estimate expose :human_time_estimate
expose :human_total_time_spent expose :human_total_time_spent
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
end end
...@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity ...@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity
expose :due_date expose :due_date
expose :moved_to_id expose :moved_to_id
expose :project_id expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
expose :web_url do |issue| expose :web_url do |issue|
project_issue_path(issue.project, issue) project_issue_path(issue.project, issue)
end end
expose :current_user do
expose :can_create_note do |issue|
can?(request.current_user, :create_note, issue.project)
end
expose :can_update do |issue|
can?(request.current_user, :update_issue, issue)
end
end
expose :create_note_path do |issue|
project_notes_path(issue.project, target_type: 'issue', target_id: issue.id)
end
expose :preview_note_path do |issue|
preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id)
end
end end
class NoteAttachmentEntity < Grape::Entity
expose :url
expose :filename
expose :image?, as: :image
end
class NoteEntity < API::Entities::Note
include RequestAwareEntity
expose :type
expose :author, using: NoteUserEntity
expose :human_access do |note|
note.project.team.human_max_access(note.author_id)
end
unexpose :note, as: :body
expose :note
expose :redacted_note_html, as: :note_html
expose :last_edited_at, if: -> (note, _) { note.edited? }
expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? }
expose :current_user do
expose :can_edit do |note|
Ability.can_edit_note?(request.current_user, note)
end
end
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note)
end
expose :discussion_id do |note|
note.discussion_id(request.noteable)
end
expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
if note.for_personal_snippet?
toggle_award_emoji_snippet_note_path(note.noteable, note)
else
toggle_award_emoji_project_note_path(note.project, note.id)
end
end
expose :report_abuse_path do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
end
expose :path do |note|
if note.for_personal_snippet?
snippet_note_path(note.noteable, note)
else
project_note_path(note.project, note)
end
end
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment