Commit e23e8695 authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'master' into feature/gb/kubernetes-only-pipeline-jobs

* master: (469 commits)
parents 339a1ad0 ebbbc7ef
......@@ -624,7 +624,7 @@ Style/PredicateName:
# branches, and conditions.
Metrics/AbcSize:
Enabled: true
Max: 56.96
Max: 55.25
# This cop checks if the length of a block exceeds some maximum value.
Metrics/BlockLength:
......
......@@ -2,6 +2,24 @@
documentation](doc/development/changelog.md) for instructions on adding your own
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)
- [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays.
......
......@@ -349,6 +349,8 @@ group :development, :test do
gem 'activerecord_sane_schema_dumper', '0.2'
gem 'stackprof', '~> 0.2.10', require: false
gem 'simple_po_parser', '~> 1.1.2', require: false
end
group :test do
......
......@@ -723,7 +723,7 @@ GEM
retriable (1.4.1)
rinku (2.0.0)
rotp (2.1.2)
rouge (2.2.0)
rouge (2.2.1)
rqrcode (0.7.0)
chunky_png
rqrcode-rails3 (0.1.7)
......@@ -833,6 +833,7 @@ GEM
faraday (~> 0.9)
jwt (~> 1.5)
multi_json (~> 1.10)
simple_po_parser (1.1.2)
simplecov (0.14.1)
docile (~> 1.1.0)
json (>= 1.8, < 3)
......@@ -1145,6 +1146,7 @@ DEPENDENCIES
sidekiq (~> 5.0)
sidekiq-cron (~> 0.6.0)
sidekiq-limit_fetch (~> 3.4)
simple_po_parser (~> 1.1.2)
simplecov (~> 0.14.0)
slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1)
......
......@@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
function Autosave(field, key) {
function Autosave(field, key, resource) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) {
key = key.join("/");
key = key.join('/');
}
this.key = "autosave/" + key;
this.field.data("autosave", this);
this.key = 'autosave/' + key;
this.field.data('autosave', this);
this.restore();
this.field.on("input", (function(_this) {
this.field.on('input', (function(_this) {
return function() {
return _this.save();
};
......@@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) {
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() {
......
......@@ -109,6 +109,7 @@ class AwardsHandler {
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
$thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
......@@ -234,14 +235,33 @@ class AwardsHandler {
}
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 $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
$('.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) {
......@@ -268,6 +288,14 @@ class AwardsHandler {
}
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');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {
......
......@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]);
$submitButton.disable();
if (!gl.utils.isInIssuePage()) {
$submitButton.disable();
}
}
});
......
......@@ -12,3 +12,4 @@ import 'core-js/fn/symbol';
// Browser polyfills
import './polyfills/custom_event';
import './polyfills/element';
import './polyfills/nodelist';
if (window.NodeList && !NodeList.prototype.forEach) {
NodeList.prototype.forEach = function forEach(callback, thisArg = window) {
for (let i = 0; i < this.length; i += 1) {
callback.call(thisArg, this[i], i, this);
}
};
}
......@@ -74,6 +74,7 @@ import PerformanceBar from './performance_bar';
import initNotes from './init_notes';
import initLegacyFilters from './init_legacy_filters';
import initIssuableSidebar from './init_issuable_sidebar';
import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown';
......@@ -98,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':');
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 enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), {
......@@ -171,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable();
new ZenMode();
initIssuableSidebar();
initNotes();
break;
case 'dashboard:milestones:index':
new ProjectSelect();
......@@ -575,6 +575,7 @@ import initChangesDropdown from './init_changes_dropdown';
break;
case 'new':
new ProjectNew();
initProjectVisibilitySelector();
break;
case 'show':
new Star();
......
......@@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue.
$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.stopPropagation();
......@@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it.
$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;
e.preventDefault();
......
......@@ -12,6 +12,7 @@ let sidebar;
export const mousePos = [];
export const setSidebar = (el) => { sidebar = el; };
export const getOpenMenu = () => currentOpenMenu;
export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; };
export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
......@@ -141,6 +142,14 @@ export const documentMouseMove = (e) => {
if (mousePos.length > 6) mousePos.shift();
};
export const subItemsMouseLeave = (relatedTarget) => {
clearTimeout(timeoutId);
if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) {
hideMenu(currentOpenMenu);
}
};
export default () => {
sidebar = document.querySelector('.nav-sidebar');
......@@ -162,10 +171,7 @@ export default () => {
const subItems = el.querySelector('.sidebar-sub-level-items');
if (subItems) {
subItems.addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
hideMenu(currentOpenMenu);
});
subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget));
}
el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget));
......
......@@ -486,7 +486,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target;
if (this.options.multiSelect) {
if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target);
if ($target && !$target.hasClass('dropdown-menu-close') &&
!$target.hasClass('dropdown-menu-close-icon') &&
......@@ -546,10 +546,10 @@ GitLabDropdown = (function() {
};
GitLabDropdown.prototype.positionMenuAbove = function() {
var $button = $(this.el);
var $menu = this.dropdown.find('.dropdown-menu');
$menu.css('top', ($button.height() + $menu.height()) * -1);
$menu.css('top', 'initial');
$menu.css('bottom', '100%');
};
GitLabDropdown.prototype.hidden = function(e) {
......@@ -698,7 +698,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.noResults = function() {
var html;
return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>";
return html = '<li class="dropdown-menu-empty-link"><a href="#" class="is-focused">No matching results</a></li>';
};
GitLabDropdown.prototype.rowClicked = function(el) {
......
......@@ -10,8 +10,6 @@ import ZenMode from './zen_mode';
(function() {
this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i;
function IssuableForm(form) {
......@@ -26,7 +24,6 @@ import ZenMode from './zen_mode';
new ZenMode();
this.titleField = this.form.find("input[name*='[title]']");
this.descriptionField = this.form.find("textarea[name*='[description]']");
this.issueMoveField = this.form.find("#move_to_project_id");
if (!(this.titleField.length && this.descriptionField.length)) {
return;
}
......@@ -34,7 +31,6 @@ import ZenMode from './zen_mode';
this.form.on("submit", this.handleSubmit);
this.form.on("click", ".btn-cancel", this.resetAutosave);
this.initWip();
this.initMoveDropdown();
$issuableDueDate = $('#issuable-due-date');
if ($issuableDueDate.length) {
calendar = new Pikaday({
......@@ -56,12 +52,6 @@ import ZenMode from './zen_mode';
};
IssuableForm.prototype.handleSubmit = function() {
var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null;
if ((parseInt(fieldId, 10) || 0) > 0) {
if (!confirm(this.issueMoveConfirmMsg)) {
return false;
}
}
return this.resetAutosave();
};
......@@ -113,48 +103,6 @@ import ZenMode from './zen_mode';
return this.titleField.val("WIP: " + (this.titleField.val()));
};
IssuableForm.prototype.initMoveDropdown = function() {
var $moveDropdown, pageSize;
$moveDropdown = $('.js-move-dropdown');
if ($moveDropdown.length) {
pageSize = $moveDropdown.data('page-size');
return $('.js-move-dropdown').select2({
ajax: {
url: $moveDropdown.data('projects-url'),
quietMillis: 125,
data: function(term, page, context) {
return {
search: term,
offset_id: context
};
},
results: function(data) {
var context,
more;
if (data.length >= pageSize)
more = true;
if (data[data.length - 1])
context = data[data.length - 1].id;
return {
results: data,
more: more,
context: context
};
}
},
formatResult: function(project) {
return project.name_with_namespace;
},
formatSelection: function(project) {
return project.name_with_namespace;
}
});
}
};
return IssuableForm;
})();
}).call(window);
......@@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() {
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;
e.preventDefault();
e.stopImmediatePropagation();
......@@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter');
if ('id' in data) {
$(document).trigger('issuable:change');
const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
$(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
......@@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
if (noteText.trim().length > 0) {
if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
......
......@@ -17,10 +17,6 @@ export default {
required: true,
type: String,
},
canMove: {
required: true,
type: Boolean,
},
canUpdate: {
required: true,
type: Boolean,
......@@ -80,11 +76,11 @@ export default {
type: Boolean,
required: true,
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
......@@ -96,10 +92,6 @@ export default {
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
data() {
const store = new Store({
......@@ -142,7 +134,6 @@ export default {
confidential: this.isConfidential,
description: this.state.descriptionText,
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
});
}
......@@ -151,16 +142,6 @@ export default {
this.showForm = false;
},
updateIssuable() {
const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
if (!canPostUpdate) {
this.store.setFormState({
updateLoading: false,
});
return;
}
this.service.updateIssuable(this.store.formState)
.then(res => res.json())
.then((data) => {
......@@ -239,14 +220,12 @@ export default {
<form-component
v-if="canUpdate && showForm"
:form-state="formState"
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl"
/>
<div v-else>
<title-component
......
......@@ -10,11 +10,11 @@
type: Object,
required: true,
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
......@@ -36,8 +36,8 @@
Description
</label>
<markdown-field
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs">
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
......
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompleteUrl,
quietMillis: 125,
data(term, page, context) {
return {
search: term,
offset_id: context,
};
},
results(data) {
const more = data.length >= 50;
const context = data[data.length - 1] ? data[data.length - 1].id : null;
return {
results: data,
more,
context,
};
},
},
formatResult(project) {
return project.name_with_namespace;
},
formatSelection(project) {
return project.name_with_namespace;
},
})
.on('change', (e) => {
this.formState.move_to_project_id = parseInt(e.target.value, 10);
});
},
beforeDestroy() {
$(this.$refs['move-dropdown']).select2('destroy');
},
};
</script>
<template>
<fieldset>
<label
for="issuable-move"
class="sr-only">
Move
</label>
<div class="issuable-form-select-holder append-right-5">
<input
ref="move-dropdown"
type="hidden"
id="issuable-move"
data-placeholder="Move to a different project" />
</div>
<span
v-tooltip
data-placement="auto top"
title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>
......@@ -4,15 +4,10 @@
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default {
props: {
canMove: {
type: Boolean,
required: true,
},
canDestroy: {
type: Boolean,
required: true,
......@@ -26,11 +21,11 @@
required: false,
default: () => [],
},
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
......@@ -42,10 +37,6 @@
type: String,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
components: {
lockedWarning,
......@@ -53,7 +44,6 @@
descriptionField,
descriptionTemplate,
editActions,
projectMove,
confidentialCheckbox,
},
computed: {
......@@ -89,14 +79,10 @@
</div>
<description-field
:form-state="formState"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" />
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
......
......@@ -28,7 +28,6 @@ document.addEventListener('DOMContentLoaded', () => {
props: {
canUpdate: this.canUpdate,
canDestroy: this.canDestroy,
canMove: this.canMove,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitleHtml: this.initialTitleHtml,
......@@ -37,11 +36,10 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs,
markdownPreviewPath: this.markdownPreviewPath,
markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
......
......@@ -6,7 +6,6 @@ export default class Store {
confidential: false,
description: '',
lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false,
};
}
......
......@@ -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) {
return $.ajax({
type: "GET",
......@@ -167,11 +174,12 @@
};
gl.utils.scrollToElement = function($el) {
var top = $el.offset().top;
gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height();
const top = $el.offset().top;
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({
scrollTop: top - (gl.mrTabsHeight)
scrollTop: top - mrTabsHeight - headerHeight,
}, 200);
};
......
......@@ -2,19 +2,20 @@ import _ from 'underscore';
(() => {
/*
* TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
* stringifyTime condensed or non-condensed, abbreviateTimelengths)
* TODO: Make these methods more configurable (e.g. stringifyTime condensed or
* non-condensed, abbreviateTimelengths)
* */
const utils = window.gl.utils = gl.utils || {};
const prettyTime = utils.prettyTime = {
/*
* 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) {
const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8;
parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) {
const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = hoursPerDay;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * 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 @@
};
</script>
<template>
<div>
<div class="ci-job-dropdown-container">
<button
v-tooltip
type="button"
......
......@@ -75,7 +75,7 @@
};
</script>
<template>
<div>
<div class="ci-job-component">
<a
v-tooltip
v-if="job.status.details_path"
......
......@@ -26,7 +26,7 @@
};
</script>
<template>
<span>
<span class="ci-job-name-component">
<ci-icon
:status="status" />
......
......@@ -14,7 +14,14 @@ export default class ProjectSelectComboButton {
bindEvents() {
this.projectSelectInput.siblings('.new-project-item-select-button')
.on('click', this.openDropdown);
.on('click', e => this.openDropdown(e));
this.newItemBtn.on('click', (e) => {
if (!this.getProjectFromLocalStorage()) {
e.preventDefault();
this.openDropdown(e);
}
});
this.projectSelectInput.on('change', () => this.selectProject());
}
......@@ -28,8 +35,9 @@ export default class ProjectSelectComboButton {
}
}
openDropdown() {
$(this).siblings('.project-item-select').select2('open');
// eslint-disable-next-line class-methods-use-this
openDropdown(event) {
$(event.currentTarget).siblings('.project-item-select').select2('open');
}
selectProject() {
......@@ -56,10 +64,8 @@ export default class ProjectSelectComboButton {
if (project) {
this.newItemBtn.attr('href', project.url);
this.newItemBtn.text(`${this.formattedText.defaultTextPrefix} in ${project.name}`);
this.newItemBtn.enable();
} else {
this.newItemBtn.text(`Select project to create ${this.formattedText.presetTextSuffix}`);
this.newItemBtn.disable();
}
}
......
function setVisibilityOptions(namespaceSelector) {
if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) {
return;
}
const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex];
const { name, visibility, visibilityLevel, showPath, editPath } = selectedNamespace.dataset;
document.querySelectorAll('.visibility-level-setting .radio').forEach((option) => {
const optionInput = option.querySelector('input[type=radio]');
const optionValue = optionInput ? optionInput.value : 0;
const optionTitle = option.querySelector('.option-title');
const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
// don't change anything if the option is restricted by admin
if (!option.classList.contains('restricted')) {
if (visibilityLevel < optionValue) {
option.classList.add('disabled');
optionInput.disabled = true;
const reason = option.querySelector('.option-disabled-reason');
if (reason) {
reason.innerHTML =
`This project cannot be ${optionName} because the visibility of
<a href="${showPath}">${name}</a> is ${visibility}. To make this project
${optionName}, you must first <a href="${editPath}">change the visibility</a>
of the parent group.`;
}
} else {
option.classList.remove('disabled');
optionInput.disabled = false;
}
}
});
}
export default function initProjectVisibilitySelector() {
const namespaceSelector = document.querySelector('select.js-select-namespace');
if (namespaceSelector) {
$('.select2.js-select-namespace').on('change', () => setVisibilityOptions(namespaceSelector));
setVisibilityOptions(namespaceSelector);
}
}
......@@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager';
Sidebar.prototype.openDropdown = function(blockOrName) {
var $block;
$block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
$block.find('.edit-link').trigger('click');
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
return this.toggleSidebar('open');
this.toggleSidebar('open');
}
// Wait for the sidebar to trigger('click') open
// so it doesn't cause our dropdown to close preemptively
setTimeout(() => {
$block.find('.js-sidebar-dropdown-toggle').trigger('click');
});
};
Sidebar.prototype.setCollapseAfterUpdate = function($block) {
......
......@@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) {
return function() {
_this.replyWithSelectedText();
_this.replyWithSelectedText(isMergeRequest);
return false;
};
})(this));
......@@ -38,9 +38,15 @@ import './shortcuts_navigation';
}
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
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();
if (!documentFragment) {
......@@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n";
});
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) {
......@@ -64,7 +71,7 @@ import './shortcuts_navigation';
});
// Trigger autosave
replyField.trigger('input');
replyField.trigger('input').trigger('change');
// Trigger autosize
var event = document.createEvent('Event');
......
......@@ -36,7 +36,7 @@ export default {
/>
<a
v-if="editable"
class="edit-link pull-right"
class="js-sidebar-dropdown-toggle edit-link pull-right"
href="#"
>
Edit
......
......@@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default {
data() {
......@@ -20,6 +21,9 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
eventHub.$on('timeTrackingUpdated', (data) => {
this.quickActionListened(null, data);
});
},
quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate'];
......
/* global Flash */
function isValidProjectId(id) {
return id > 0;
}
class SidebarMoveIssue {
constructor(mediator, dropdownToggle, confirmButton) {
this.mediator = mediator;
this.$dropdownToggle = $(dropdownToggle);
this.$confirmButton = $(confirmButton);
this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this);
}
init() {
this.initDropdown();
this.addEventListeners();
}
destroy() {
this.removeEventListeners();
}
initDropdown() {
this.$dropdownToggle.glDropdown({
search: {
fields: ['name_with_namespace'],
},
showMenuAbove: true,
selectable: true,
filterable: true,
filterRemote: true,
multiSelect: false,
// Keep the dropdown open after selecting an option
shouldPropagate: false,
data: (searchTerm, callback) => {
this.mediator.fetchAutocompleteProjects(searchTerm)
.then(callback)
.catch(() => new Flash('An error occured while fetching projects autocomplete.'));
},
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
${project.name_with_namespace}
</a>
</li>
`,
clicked: (options) => {
const project = options.selectedObj;
const selectedProjectId = options.isMarking ? project.id : 0;
this.mediator.setMoveToProjectId(selectedProjectId);
this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId));
},
});
}
addEventListeners() {
this.$confirmButton.on('click', this.onConfirmClickedWrapper);
}
removeEventListeners() {
this.$confirmButton.off('click', this.onConfirmClickedWrapper);
}
onConfirmClicked() {
if (isValidProjectId(this.mediator.store.moveToProjectId)) {
this.$confirmButton
.disable()
.addClass('is-loading');
this.mediator.moveIssue()
.catch(() => {
Flash('An error occured while moving the issue.');
this.$confirmButton
.enable()
.removeClass('is-loading');
});
}
}
}
export default SidebarMoveIssue;
......@@ -4,9 +4,11 @@ import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService {
constructor(endpoint) {
constructor(endpointMap) {
if (!SidebarService.singleton) {
this.endpoint = endpoint;
this.endpoint = endpointMap.endpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
SidebarService.singleton = this;
}
......@@ -25,4 +27,18 @@ export default class SidebarService {
emulateJSON: true,
});
}
getProjectsAutocomplete(searchTerm) {
return Vue.http.get(this.projectsAutocompleteEndpoint, {
params: {
search: searchTerm,
},
});
}
moveIssue(moveToProjectId) {
return Vue.http.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId,
});
}
}
......@@ -2,6 +2,7 @@ import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees';
import confidential from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import Mediator from './sidebar_mediator';
......@@ -31,6 +32,12 @@ function domContentLoaded() {
service: mediator.service,
},
}).$mount(confidentialEl);
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
}
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
......
......@@ -7,7 +7,11 @@ export default class SidebarMediator {
constructor(options) {
if (!SidebarMediator.singleton) {
this.store = new Store(options);
this.service = new Service(options.endpoint);
this.service = new Service({
endpoint: options.endpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
SidebarMediator.singleton = this;
}
......@@ -26,6 +30,10 @@ export default class SidebarMediator {
return this.service.update(field, selected.length === 0 ? [0] : selected);
}
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
fetch() {
this.service.get()
.then(response => response.json())
......@@ -35,4 +43,23 @@ export default class SidebarMediator {
})
.catch(() => new Flash('Error occured when fetching sidebar data'));
}
fetchAutocompleteProjects(searchTerm) {
return this.service.getProjectsAutocomplete(searchTerm)
.then(response => response.json())
.then((data) => {
this.store.setAutocompleteProjects(data);
return this.store.autocompleteProjects;
});
}
moveIssue() {
return this.service.moveIssue(this.store.moveToProjectId)
.then(response => response.json())
.then((data) => {
if (location.pathname !== data.web_url) {
gl.utils.visitUrl(data.web_url);
}
});
}
}
......@@ -13,6 +13,8 @@ export default class SidebarStore {
this.isFetching = {
assignees: true,
};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
SidebarStore.singleton = this;
}
......@@ -53,4 +55,12 @@ export default class SidebarStore {
removeAllAssignees() {
this.assignees = [];
}
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
}
<script>
import commitIconSvg from 'icons/_icon_commit.svg';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
import tooltip from '../directives/tooltip';
export default {
props: {
......@@ -100,17 +101,22 @@
this.author.username ? `${this.author.username}'s avatar` : null;
},
},
data() {
return { commitIconSvg };
directives: {
tooltip,
},
components: {
userAvatarLink,
},
created() {
this.commitIconSvg = commitIconSvg;
},
};
</script>
<template>
<div class="branch-commit">
<div v-if="hasCommitRef" class="icon-container hidden-xs">
<div
v-if="hasCommitRef"
class="icon-container hidden-xs">
<i
v-if="tag"
class="fa fa-tag"
......@@ -126,7 +132,10 @@
<a
v-if="hasCommitRef"
class="ref-name hidden-xs"
:href="commitRef.ref_url">
:href="commitRef.ref_url"
v-tooltip
data-container="body"
:title="commitRef.name">
{{commitRef.name}}
</a>
......@@ -153,7 +162,8 @@
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
/>
<a class="commit-row-message"
<a
class="commit-row-message"
:href="commitUrl">
{{title}}
</a>
......
<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 @@
export default {
props: {
markdownPreviewUrl: {
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
addSpacingClasses: {
type: Boolean,
required: false,
default: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
},
data() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
......@@ -26,35 +37,48 @@
markdownHeader,
markdownToolbar,
},
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
},
methods: {
toggleMarkdownPreview() {
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) {
this.markdownPreview = '';
} else {
} else if (text) {
this.markdownPreviewLoading = true;
this.$http.post(
this.markdownPreviewUrl,
{
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
text: this.$slots.textarea[0].elm.value,
},
)
.then(resp => resp.json())
.then((data) => {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body;
this.$http.post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then((data) => {
this.renderMarkdown(data);
})
.catch(() => new Flash('Error loading markdown preview'));
} else {
this.renderMarkdown();
}
},
renderMarkdown(data = {}) {
this.markdownPreviewLoading = false;
this.markdownPreview = data.body || 'Nothing to preview.';
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => new Flash('Error loading markdown preview'));
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
}
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
},
},
mounted() {
......@@ -74,7 +98,8 @@
<template>
<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">
<markdown-header
:preview-markdown="previewMarkdown"
......@@ -94,7 +119,9 @@
</i>
</a>
<markdown-toolbar
:markdown-docs="markdownDocs" />
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
/>
</div>
</div>
<div
......@@ -108,5 +135,27 @@
Loading...
</span>
</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>
</template>
<script>
export default {
props: {
markdownDocs: {
markdownDocsPath: {
type: String,
required: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
},
};
</script>
......@@ -12,22 +16,77 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<a
:href="markdownDocs"
target="_blank"
tabindex="-1">
Markdown is supported
</a>
<template v-if="!quickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
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>
<button
class="toolbar-button markdown-selector"
type="button"
tabindex="-1">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true">
</i>
Attach a file
</button>
<span class="uploading-container">
<span class="uploading-progress-container hide">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
<span class="attaching-file-message"></span>
<span class="uploading-progress">0%</span>
<span class="uploading-spinner">
<i
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>
</template>
......@@ -95,8 +95,8 @@
.is-selected .pika-day,
.pika-day:hover,
.is-today .pika-day {
background: $gl-primary;
color: $white-light;
background: $gray-darker;
color: $gl-text-color;
box-shadow: none;
}
}
......@@ -16,6 +16,7 @@
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
......
......@@ -193,7 +193,7 @@
min-width: 240px;
max-width: 500px;
margin-top: 2px;
margin-bottom: 0;
margin-bottom: 2px;
font-size: 14px;
font-weight: $gl-font-weight-normal;
padding: 8px 0;
......@@ -368,6 +368,10 @@
transform: translateY(0);
}
.comment-type-dropdown.open .dropdown-menu {
display: block;
}
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
......@@ -618,6 +622,11 @@
border-top: 1px solid $dropdown-divider-color;
}
.dropdown-footer-content {
padding-left: 10px;
padding-right: 10px;
}
.dropdown-due-date-footer {
padding-top: 0;
margin-left: 10px;
......@@ -729,6 +738,7 @@
#{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav {
li {
display: block;
padding: 0 1px;
&:hover {
......@@ -748,9 +758,13 @@
}
a,
button {
button,
.menu-item {
border-radius: 0;
box-shadow: none;
padding: 8px 16px;
text-align: left;
width: 100%;
// make sure the text color is not overriden
&.text-danger {
......@@ -796,6 +810,15 @@
#{$selector}.dropdown-menu-align-right {
margin-top: 2px;
}
.open {
#{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav {
@media (max-width: $screen-xs-max) {
max-width: 100%;
}
}
}
}
@include new-style-dropdown('.js-namespace-select + ');
......@@ -132,3 +132,7 @@
width: calc(100% + 35px);
}
}
.issuable-sidebar {
@include new-style-dropdown;
}
......@@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
$stage-hover-bg: #eaf3fc;
$stage-hover-border: #d1e7fc;
$action-icon-color: #d6d6d6;
$stage-hover-bg: $gray-darker;
$ci-action-icon-size: 22px;
$pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
/*
Pipeline Schedules
......
......@@ -8,15 +8,23 @@ header.navbar-gitlab-new {
border-bottom: 0;
.header-content {
display: -webkit-flex;
display: flex;
padding-left: 0;
.title-container {
display: -webkit-flex;
display: flex;
-webkit-align-items: stretch;
align-items: stretch;
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
padding-top: 0;
overflow: visible;
}
.title {
display: -webkit-flex;
display: flex;
padding-right: 0;
color: currentColor;
......@@ -27,6 +35,7 @@ header.navbar-gitlab-new {
}
> a {
display: -webkit-flex;
display: flex;
align-items: center;
padding-right: $gl-padding;
......@@ -177,6 +186,7 @@ header.navbar-gitlab-new {
}
.navbar-sub-nav {
display: -webkit-flex;
display: flex;
margin-bottom: 0;
color: $indigo-200;
......
......@@ -322,14 +322,13 @@
}
.build-dropdown {
padding: $gl-padding 0;
@include new-style-dropdown;
.dropdown-menu-toggle {
margin-top: 8px;
}
margin: $gl-padding 0;
padding: 0;
.dropdown-menu {
margin-top: -$gl-padding;
.dropdown-menu-toggle {
margin-top: #{$gl-padding / 2};
}
svg {
......
......@@ -12,6 +12,8 @@
.environments-container {
.ci-table {
@include new-style-dropdown;
.deployment-column {
> span {
word-break: break-all;
......
......@@ -473,7 +473,7 @@
padding-top: 6px;
}
.open .dropdown-menu {
.dropdown-menu {
width: 100%;
}
}
......@@ -486,6 +486,24 @@
}
}
.sidebar-move-issue-dropdown {
@include new-style-dropdown;
}
.sidebar-move-issue-confirmation-button {
width: 100%;
&.is-loading {
.sidebar-move-issue-confirmation-loading-icon {
display: inline-block;
}
}
}
.sidebar-move-issue-confirmation-loading-icon {
display: none;
}
.detail-page-description {
padding: 16px 0;
......@@ -498,6 +516,7 @@
color: $gray-darkest;
display: block;
margin: 16px 0 0;
font-size: 85%;
.author_link {
color: $gray-darkest;
......
......@@ -186,6 +186,8 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
@include new-style-dropdown;
.btn-group:not(.hide) {
display: flex;
}
......@@ -212,15 +214,6 @@ ul.related-merge-requests > li {
}
li:not(.divider) {
padding: 6px;
cursor: pointer;
&:hover,
&:focus {
background-color: $dropdown-hover-color;
color: $white-light;
}
&.droplab-item-selected {
.icon-container {
i {
......@@ -250,6 +243,10 @@ ul.related-merge-requests > li {
}
}
.discussion-reply-holder .note-edit-form {
display: block;
}
@media (min-width: $screen-sm-min) {
.emoji-block .row {
display: flex;
......
......@@ -116,6 +116,8 @@
}
.manage-labels-list {
@include new-style-dropdown;
> li:not(.empty-message):not(.is-not-draggable) {
background-color: $white-light;
cursor: move;
......
......@@ -55,6 +55,10 @@
display: -webkit-flex;
display: flex;
}
.dropdown-menu.dropdown-menu-align-right {
margin-top: -2px;
}
}
.form-horizontal {
......@@ -306,3 +310,7 @@
}
}
}
.member-form-control {
@include new-style-dropdown;
}
......@@ -174,17 +174,6 @@
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 {
line-height: 28px;
}
......@@ -291,6 +280,7 @@
.dropdown-toggle {
.fa {
margin-left: 0;
color: inherit;
}
}
......@@ -731,3 +721,7 @@
font-size: 16px;
}
}
.merge-request-form {
@include new-style-dropdown;
}
......@@ -20,10 +20,6 @@
}
}
.new-note {
display: none;
}
.new-note,
.note-edit-form {
.note-form-actions {
......@@ -202,6 +198,10 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
&.is-replying {
padding-bottom: $gl-padding;
}
}
}
......
......@@ -100,6 +100,20 @@ ul.notes {
}
}
.editing-spinner {
display: none;
}
&.is-requesting {
.note-timestamp {
display: none;
}
.editing-spinner {
display: inline-block;
}
}
&.is-editing {
.note-header,
.note-text,
......@@ -365,9 +379,7 @@ ul.notes {
}
.discussion-header,
.note-header {
position: relative;
.note-header-info {
a {
color: inherit;
......@@ -402,6 +414,10 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
&.discussion {
padding-bottom: 0;
}
}
.system-note .note-header-info {
......@@ -453,6 +469,8 @@ ul.notes {
}
.note-actions {
@include new-style-dropdown;
align-self: flex-start;
flex-shrink: 0;
display: inline-flex;
......@@ -488,22 +506,6 @@ ul.notes {
.more-actions-dropdown {
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 {
......@@ -814,10 +816,6 @@ ul.notes {
}
}
.discussion-notes .flash-container {
margin-bottom: 0;
}
// Merge request notes in diffs
.diff-file {
// Diff is inline
......
......@@ -40,7 +40,7 @@
.btn.btn-retry:hover,
.btn.btn-retry:focus {
border-color: $gray-darkest;
border-color: $dropdown-toggle-active-border-color;
background-color: $white-normal;
}
......@@ -206,8 +206,8 @@
.stage-cell {
.mini-pipeline-graph-dropdown-toggle svg {
height: 22px;
width: 22px;
height: $ci-action-icon-size;
width: $ci-action-icon-size;
position: absolute;
top: -1px;
left: -1px;
......@@ -219,7 +219,7 @@
display: inline-block;
position: relative;
vertical-align: middle;
height: 22px;
height: $ci-action-icon-size;
margin: 3px 0;
+ .stage-container {
......@@ -257,6 +257,8 @@
// Pipeline visualization
.pipeline-actions {
@include new-style-dropdown;
border-bottom: none;
}
......@@ -308,7 +310,7 @@
a {
text-decoration: none;
color: $gl-text-color-secondary;
color: $gl-text-color;
}
svg {
......@@ -432,7 +434,11 @@
width: 186px;
margin-bottom: 10px;
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
.ci-action-icon-container .ci-action-icon-wrapper {
......@@ -445,11 +451,11 @@
&:hover {
background-color: $stage-hover-bg;
border: 1px solid $stage-hover-bg;
border: 1px solid $dropdown-toggle-active-border-color;
}
svg {
fill: $border-color;
fill: $gl-text-color-secondary;
position: relative;
left: -1px;
top: -1px;
......@@ -475,19 +481,10 @@
background-color: transparent;
border: none;
padding: 0;
color: $gl-text-color-secondary;
&:focus {
outline: none;
}
&:hover {
color: $gl-text-color;
.dropdown-counter-badge {
color: $gl-text-color;
}
}
}
.build-content {
......@@ -502,8 +499,7 @@
a.build-content:hover,
button.build-content:hover {
background-color: $stage-hover-bg;
border: 1px solid $stage-hover-border;
color: $gl-text-color;
border: 1px solid $dropdown-toggle-active-border-color;
}
......@@ -564,7 +560,6 @@
// Triggers the dropdown in the big pipeline graph
.dropdown-counter-badge {
color: $border-color;
font-weight: 100;
font-size: 15px;
position: absolute;
......@@ -606,8 +601,8 @@ button.mini-pipeline-graph-dropdown-toggle {
background-color: $white-light;
border-width: 1px;
border-style: solid;
width: 22px;
height: 22px;
width: $ci-action-icon-size;
height: $ci-action-icon-size;
margin: 0;
padding: 0;
transition: all 0.2s linear;
......@@ -669,105 +664,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
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
width: 195px;
max-width: 195px;
li {
padding: 2px 3px;
}
.scrollable-menu {
padding: 0;
max-height: 245px;
overflow: auto;
}
// Action icon on the right
a.ci-action-icon-wrapper {
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;
li {
position: relative;
&:hover,
&:focus {
background-color: $stage-hover-bg;
border: 1px solid transparent;
// ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
&:hover > .mini-pipeline-graph-dropdown-item,
&:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
@extend .mini-pipeline-graph-dropdown-item:hover;
}
svg {
width: 22px;
height: 22px;
left: -6px;
position: relative;
top: -3px;
fill: $action-icon-color;
}
// Action icon on the right
a.ci-action-icon-wrapper {
border-radius: 50%;
border: 1px solid $border-color;
width: $ci-action-icon-size;
height: $ci-action-icon-size;
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,
&:focus svg {
fill: $gl-text-color;
}
}
&:hover,
&:focus {
background-color: $stage-hover-bg;
border: 1px solid $dropdown-toggle-active-border-color;
}
// link to the build
.mini-pipeline-graph-dropdown-item {
padding: 3px 7px 4px;
clear: both;
font-weight: $gl-font-weight-normal;
line-height: 1.428571429;
white-space: nowrap;
margin: 0 5px;
border-radius: 3px;
svg {
fill: $gl-text-color-secondary;
width: $ci-action-icon-size;
height: $ci-action-icon-size;
left: -6px;
position: relative;
top: -3px;
}
// build name
.ci-build-text,
.ci-status-text {
font-weight: 200;
overflow: hidden;
&:hover svg,
&:focus svg {
fill: $gl-text-color;
}
}
// 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;
text-overflow: ellipsis;
max-width: 70%;
color: $gl-text-color-secondary;
margin-left: 2px;
display: inline-block;
top: 1px;
vertical-align: text-bottom;
position: relative;
border-radius: 3px;
@media (max-width: $screen-xs-max) {
max-width: 60%;
.ci-job-name-component {
align-items: center;
display: flex;
flex: 1;
}
}
// status icon on the left
.ci-status-icon {
top: 3px;
position: relative;
// build name
.ci-build-text,
.ci-status-text {
font-weight: 200;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 70%;
margin-left: 2px;
display: inline-block;
> svg {
overflow: visible;
width: 18px;
height: 18px;
@media (max-width: $screen-xs-max) {
max-width: 60%;
}
}
}
&:hover,
&:focus {
outline: none;
text-decoration: none;
color: $gl-text-color;
background-color: $stage-hover-bg;
.ci-status-icon {
@extend .append-right-8;
position: relative;
> 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 +785,9 @@ button.mini-pipeline-graph-dropdown-toggle {
.big-pipeline-graph-dropdown-menu {
width: 195px;
min-width: 195px;
left: auto;
right: -195px;
top: -4px;
left: 100%;
top: -10px;
box-shadow: 0 1px 5px $black-transparent;
.mini-pipeline-graph-dropdown-item {
.ci-status-icon {
top: -1px;
}
}
}
/**
......@@ -806,15 +808,14 @@ button.mini-pipeline-graph-dropdown-toggle {
}
&::before {
left: -5px;
margin-top: -6px;
left: -6px;
margin-top: 3px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
left: -4px;
margin-top: -9px;
left: -5px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
......
......@@ -299,28 +299,6 @@
}
}
.project-visibility-level-holder {
.radio {
margin-bottom: 10px;
i {
margin: 2px 0;
font-size: 20px;
}
.option-title {
font-weight: $gl-font-weight-normal;
display: inline-block;
color: $gl-text-color;
}
.option-descr {
margin-left: 29px;
color: $project-option-descr-color;
}
}
}
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
......
......@@ -143,6 +143,47 @@
}
}
.visibility-level-setting {
.radio {
margin-bottom: 10px;
i.fa {
margin: 2px 0;
font-size: 20px;
}
.option-title {
font-weight: $gl-font-weight-normal;
display: inline-block;
color: $gl-text-color;
}
.option-description,
.option-disabled-reason {
margin-left: 29px;
color: $project-option-descr-color;
}
.option-disabled-reason {
display: none;
}
&.disabled {
i.fa {
opacity: 0.5;
}
.option-description {
display: none;
}
.option-disabled-reason {
display: block;
}
}
}
}
.prometheus-metrics-monitoring {
.panel {
.panel-toggle {
......
......@@ -117,11 +117,14 @@ class Admin::UsersController < Admin::ApplicationController
user_params_with_pass = user_params.dup
if params[:user][:password].present?
user_params_with_pass.merge!(
password_params = {
password: params[:user][:password],
password_confirmation: params[:user][:password_confirmation],
password_expires_at: Time.now
)
password_confirmation: params[:user][:password_confirmation]
}
password_params[:password_expires_at] = Time.now unless changing_own_password?
user_params_with_pass.merge!(password_params)
end
respond_to do |format|
......@@ -167,6 +170,10 @@ class Admin::UsersController < Admin::ApplicationController
protected
def changing_own_password?
user == current_user
end
def user
@user ||= User.find_by!(username: params[:id])
end
......
......@@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base
end
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
end
end
......
......@@ -41,12 +41,6 @@ class AutocompleteController < ApplicationController
project = Project.find_by_id(params[:project_id])
projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
no_project = {
id: 0,
name_with_namespace: 'No project'
}
projects.unshift(no_project) unless params[:offset_id].present?
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end
......
......@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern
included do
before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
end
......@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at }
@notes = notes_finder.execute.inc_relations_for_view
@notes = prepare_notes_for_rendering(@notes)
notes = notes_finder.execute
.inc_relations_for_view
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
@notes.each do |note|
next if note.cross_reference_not_visible_for?(current_user)
notes = prepare_notes_for_rendering(notes)
notes_json[:notes] << note_json(note)
end
notes_json[:notes] =
if noteable.discussions_rendered_on_frontend?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
end
render json: notes_json
end
......@@ -82,22 +87,27 @@ module NotesActions
}
if note.persisted?
attrs.merge!(
valid: true,
id: note.id,
discussion_id: note.discussion_id(noteable),
html: note_html(note),
note: note.note
)
attrs[:valid] = true
discussion = note.to_discussion(noteable)
unless discussion.individual_note?
if noteable.nil? || noteable.discussions_rendered_on_frontend?
attrs.merge!(note_serializer.represent(note))
else
attrs.merge!(
discussion_resolvable: discussion.resolvable?,
diff_discussion_html: diff_discussion_html(discussion),
discussion_html: discussion_html(discussion)
id: note.id,
discussion_id: note.discussion_id(noteable),
html: note_html(note),
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
else
attrs.merge!(
......@@ -168,6 +178,10 @@ module NotesActions
)
end
def set_polling_interval_header
Gitlab::PollingInterval.set_header(response, interval: 6_000)
end
def noteable
@noteable ||= notes_finder.target
end
......@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end
def note_serializer
NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
end
def note_project
return @note_project if defined?(@note_project)
return nil unless project
......
module RequiresWhitelistedMonitoringClient
extend ActiveSupport::Concern
include Gitlab::CurrentSettings
included do
before_action :validate_ip_whitelisted_or_valid_token!
end
......
class PasswordsController < Devise::PasswordsController
include Gitlab::CurrentSettings
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]
def edit
......@@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController
self.resource = resource_class.find_by_email(email)
end
def check_password_authentication_available
return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?)
def prevent_ldap_reset
return unless resource&.ldap_user?
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
alert: "Password authentication is unavailable."
alert: "Cannot reset password for LDAP user."
end
def throttle_reset
......
......@@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
end
def authorize_change_password!
render_404 unless @user.allow_password_authentication?
render_404 if @user.ldap_user?
end
def user_params
......
......@@ -94,6 +94,6 @@ class Projects::ApplicationController < ApplicationController
end
def require_pages_enabled!
not_found unless Gitlab.config.pages.enabled
not_found unless @project.pages_available?
end
end
......@@ -20,7 +20,10 @@ class ProjectsController < Projects::ApplicationController
end
def new
@project = Project.new
namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
return access_denied! if namespace && !can?(current_user, :create_projects, namespace)
@project = Project.new(namespace_id: namespace&.id)
end
def edit
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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