Merge branch 'master' into feature/gb/kubernetes-only-pipeline-jobs
* master: (469 commits)
This commit is contained in:
commit
e23e86953d
|
@ -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:
|
||||
|
|
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -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.
|
||||
|
|
|
@ -1 +1 @@
|
|||
0.33.0
|
||||
0.36.0
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
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"
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
<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;
|
||||
|
|
|
@ -0,0 +1,347 @@
|
|||
<script>
|
||||
/* global Flash, Autosave */
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import _ from 'underscore';
|
||||
import '../../autosave';
|
||||
import TaskList from '../../task_list';
|
||||
import * as constants from '../constants';
|
||||
import eventHub from '../event_hub';
|
||||
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
|
||||
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
|
||||
import markdownField from '../../vue_shared/components/markdown/field.vue';
|
||||
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
|
||||
export default {
|
||||
name: 'issueCommentForm',
|
||||
data() {
|
||||
return {
|
||||
note: '',
|
||||
noteType: constants.COMMENT,
|
||||
// Can't use mapGetters,
|
||||
// this needs to be in the data object because it belongs to the state
|
||||
issueState: this.$store.getters.getIssueData.state,
|
||||
isSubmitting: false,
|
||||
isSubmitButtonDisabled: true,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
confidentialIssue,
|
||||
issueNoteSignedOutWidget,
|
||||
markdownField,
|
||||
userAvatarLink,
|
||||
},
|
||||
watch: {
|
||||
note(newNote) {
|
||||
this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
|
||||
},
|
||||
isSubmitting(newValue) {
|
||||
this.setIsSubmitButtonDisabled(this.note, newValue);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
'getCurrentUserLastNote',
|
||||
'getUserData',
|
||||
'getIssueData',
|
||||
'getNotesData',
|
||||
]),
|
||||
isLoggedIn() {
|
||||
return this.getUserData.id;
|
||||
},
|
||||
commentButtonTitle() {
|
||||
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
|
||||
},
|
||||
isIssueOpen() {
|
||||
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
|
||||
},
|
||||
issueActionButtonTitle() {
|
||||
if (this.note.length) {
|
||||
const actionText = this.isIssueOpen ? 'close' : 'reopen';
|
||||
|
||||
return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
|
||||
}
|
||||
|
||||
return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
|
||||
},
|
||||
actionButtonClassNames() {
|
||||
return {
|
||||
'btn-reopen': !this.isIssueOpen,
|
||||
'btn-close': this.isIssueOpen,
|
||||
'js-note-target-close': this.isIssueOpen,
|
||||
'js-note-target-reopen': !this.isIssueOpen,
|
||||
};
|
||||
},
|
||||
markdownDocsPath() {
|
||||
return this.getNotesData.markdownDocsPath;
|
||||
},
|
||||
quickActionsDocsPath() {
|
||||
return this.getNotesData.quickActionsDocsPath;
|
||||
},
|
||||
markdownPreviewPath() {
|
||||
return this.getIssueData.preview_note_path;
|
||||
},
|
||||
author() {
|
||||
return this.getUserData;
|
||||
},
|
||||
canUpdateIssue() {
|
||||
return this.getIssueData.current_user.can_update;
|
||||
},
|
||||
endpoint() {
|
||||
return this.getIssueData.create_note_path;
|
||||
},
|
||||
isConfidentialIssue() {
|
||||
return this.getIssueData.confidential;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions([
|
||||
'saveNote',
|
||||
'removePlaceholderNotes',
|
||||
]),
|
||||
setIsSubmitButtonDisabled(note, isSubmitting) {
|
||||
if (!_.isEmpty(note) && !isSubmitting) {
|
||||
this.isSubmitButtonDisabled = false;
|
||||
} else {
|
||||
this.isSubmitButtonDisabled = true;
|
||||
}
|
||||
},
|
||||
handleSave(withIssueAction) {
|
||||
if (this.note.length) {
|
||||
const noteData = {
|
||||
endpoint: this.endpoint,
|
||||
flashContainer: this.$el,
|
||||
data: {
|
||||
note: {
|
||||
noteable_type: constants.NOTEABLE_TYPE,
|
||||
noteable_id: this.getIssueData.id,
|
||||
note: this.note,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (this.noteType === constants.DISCUSSION) {
|
||||
noteData.data.note.type = constants.DISCUSSION_NOTE;
|
||||
}
|
||||
this.isSubmitting = true;
|
||||
this.note = ''; // Empty textarea while being requested. Repopulate in catch
|
||||
|
||||
this.saveNote(noteData)
|
||||
.then((res) => {
|
||||
this.isSubmitting = false;
|
||||
if (res.errors) {
|
||||
if (res.errors.commands_only) {
|
||||
this.discard();
|
||||
} else {
|
||||
Flash(
|
||||
'Something went wrong while adding your comment. Please try again.',
|
||||
'alert',
|
||||
$(this.$refs.commentForm),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.discard();
|
||||
}
|
||||
|
||||
if (withIssueAction) {
|
||||
this.toggleIssueState();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.isSubmitting = false;
|
||||
this.discard(false);
|
||||
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
|
||||
Flash(msg, 'alert', $(this.$el));
|
||||
this.note = noteData.data.note.note; // Restore textarea content.
|
||||
this.removePlaceholderNotes();
|
||||
});
|
||||
} else {
|
||||
this.toggleIssueState();
|
||||
}
|
||||
},
|
||||
toggleIssueState() {
|
||||
this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
|
||||
|
||||
// This is out of scope for the Notes Vue component.
|
||||
// It was the shortest path to update the issue state and relevant places.
|
||||
const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
|
||||
$(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
|
||||
},
|
||||
discard(shouldClear = true) {
|
||||
// `blur` is needed to clear slash commands autocomplete cache if event fired.
|
||||
// `focus` is needed to remain cursor in the textarea.
|
||||
this.$refs.textarea.blur();
|
||||
this.$refs.textarea.focus();
|
||||
|
||||
if (shouldClear) {
|
||||
this.note = '';
|
||||
}
|
||||
|
||||
// reset autostave
|
||||
this.autosave.reset();
|
||||
},
|
||||
setNoteType(type) {
|
||||
this.noteType = type;
|
||||
},
|
||||
editCurrentUserLastNote() {
|
||||
if (this.note === '') {
|
||||
const lastNote = this.getCurrentUserLastNote;
|
||||
|
||||
if (lastNote) {
|
||||
eventHub.$emit('enterEditMode', {
|
||||
noteId: lastNote.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
initAutoSave() {
|
||||
if (this.isLoggedIn) {
|
||||
this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
|
||||
}
|
||||
},
|
||||
initTaskList() {
|
||||
return new TaskList({
|
||||
dataType: 'note',
|
||||
fieldName: 'note',
|
||||
selector: '.notes',
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// jQuery is needed here because it is a custom event being dispatched with jQuery.
|
||||
$(document).on('issuable:change', (e, isClosed) => {
|
||||
this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
|
||||
});
|
||||
|
||||
this.initAutoSave();
|
||||
this.initTaskList();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<issue-note-signed-out-widget v-if="!isLoggedIn" />
|
||||
<ul
|
||||
v-else
|
||||
class="notes notes-form timeline">
|
||||
<li class="timeline-entry">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="flash-container error-alert timeline-content"></div>
|
||||
<div class="timeline-icon hidden-xs hidden-sm">
|
||||
<user-avatar-link
|
||||
v-if="author"
|
||||
:link-href="author.path"
|
||||
:img-src="author.avatar_url"
|
||||
:img-alt="author.name"
|
||||
:img-size="40"
|
||||
/>
|
||||
</div>
|
||||
<div class="timeline-content timeline-content-form">
|
||||
<form
|
||||
ref="commentForm"
|
||||
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
|
||||
<confidentialIssue v-if="isConfidentialIssue" />
|
||||
<div class="error-alert"></div>
|
||||
<markdown-field
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:markdown-docs-path="markdownDocsPath"
|
||||
:quick-actions-docs-path="quickActionsDocsPath"
|
||||
:add-spacing-classes="false"
|
||||
:is-confidential-issue="isConfidentialIssue">
|
||||
<textarea
|
||||
id="note-body"
|
||||
name="note[note]"
|
||||
class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea"
|
||||
data-supports-quick-actions="true"
|
||||
aria-label="Description"
|
||||
v-model="note"
|
||||
ref="textarea"
|
||||
slot="textarea"
|
||||
placeholder="Write a comment or drag your files here..."
|
||||
@keydown.up="editCurrentUserLastNote()"
|
||||
@keydown.meta.enter="handleSave()">
|
||||
</textarea>
|
||||
</markdown-field>
|
||||
<div class="note-form-actions">
|
||||
<div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
|
||||
<button
|
||||
@click.prevent="handleSave()"
|
||||
:disabled="isSubmitButtonDisabled"
|
||||
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
|
||||
type="submit">
|
||||
{{commentButtonTitle}}
|
||||
</button>
|
||||
<button
|
||||
:disabled="isSubmitButtonDisabled"
|
||||
name="button"
|
||||
type="button"
|
||||
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
aria-label="Open comment type dropdown">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="fa fa-caret-down toggle-icon">
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<ul class="note-type-dropdown dropdown-open-top dropdown-menu">
|
||||
<li :class="{ 'droplab-item-selected': noteType === 'comment' }">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-transparent"
|
||||
@click.prevent="setNoteType('comment')">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="fa fa-check icon">
|
||||
</i>
|
||||
<div class="description">
|
||||
<strong>Comment</strong>
|
||||
<p>
|
||||
Add a general comment to this issue.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
<li class="divider droplab-item-ignore"></li>
|
||||
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-transparent"
|
||||
@click.prevent="setNoteType('discussion')">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="fa fa-check icon">
|
||||
</i>
|
||||
<div class="description">
|
||||
<strong>Start discussion</strong>
|
||||
<p>
|
||||
Discuss a specific suggestion or question.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSave(true)"
|
||||
v-if="canUpdateIssue"
|
||||
:class="actionButtonClassNames"
|
||||
class="btn btn-comment btn-comment-and-close">
|
||||
{{issueActionButtonTitle}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
v-if="note.length"
|
||||
@click="discard"
|
||||
class="btn btn-cancel js-note-discard">
|
||||
Discard draft
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,232 @@
|
|||
<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>
|
|
@ -0,0 +1,186 @@
|
|||
<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>
|
|
@ -0,0 +1,167 @@
|
|||
<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>
|
|
@ -0,0 +1,37 @@
|
|||
<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>
|
|
@ -0,0 +1,228 @@
|
|||
<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>
|
|
@ -0,0 +1,122 @@
|
|||
<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>
|
|
@ -0,0 +1,47 @@
|
|||
<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>
|
|
@ -0,0 +1,166 @@
|
|||
<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>
|
|
@ -0,0 +1,118 @@
|
|||
<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>
|
|
@ -0,0 +1,37 @@
|
|||
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,
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
<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>
|
|
@ -0,0 +1,151 @@
|
|||
<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>
|
|
@ -0,0 +1,53 @@
|
|||
<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>
|
|
@ -0,0 +1,21 @@
|
|||
<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>
|
|
@ -0,0 +1,55 @@
|
|||
<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>
|
|
@ -0,0 +1,11 @@
|
|||
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';
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -0,0 +1,35 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
|
@ -0,0 +1,16 @@
|
|||
/* 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();
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
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 });
|
||||
},
|
||||
};
|
|
@ -0,0 +1,217 @@
|
|||
/* 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);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
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));
|
|
@ -0,0 +1,23 @@
|
|||
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,
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
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';
|
|
@ -0,0 +1,151 @@
|
|||
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);
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
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'];
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<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>
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<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,36 +37,49 @@
|
|||
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.$nextTick(() => {
|
||||
$(this.$refs['markdown-preview']).renderGFM();
|
||||
});
|
||||
})
|
||||
.catch(() => new Flash('Error loading markdown preview'));
|
||||
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.';
|
||||
|
||||
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>
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
<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;
|
||||
|
||||
margin: $gl-padding 0;
|
||||
padding: 0;
|
||||
|
||||
.dropdown-menu-toggle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
margin-top: -$gl-padding;
|
||||
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,
|
||||
&:focus {
|
||||
background-color: $stage-hover-bg;
|
||||
border: 1px solid $dropdown-toggle-active-border-color;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: $gl-text-color-secondary;
|
||||
width: $ci-action-icon-size;
|
||||
height: $ci-action-icon-size;
|
||||
left: -6px;
|
||||
position: relative;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
&:hover svg,
|
||||
&:focus svg {
|
||||
fill: $gl-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover svg,
|
||||
&:focus svg {
|
||||
fill: $gl-text-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;
|
||||
|
||||
// build name
|
||||
.ci-build-text,
|
||||
.ci-status-text {
|
||||
font-weight: 200;
|
||||
overflow: hidden;
|
||||
// link to the build
|
||||
.mini-pipeline-graph-dropdown-item {
|
||||
padding: 3px 7px 4px;
|
||||
align-items: center;
|
||||
clear: both;
|
||||
display: flex;
|
||||
font-weight: normal;
|
||||
line-height: $line-height-base;
|
||||
white-space: nowrap;
|
||||
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
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
module RequiresWhitelistedMonitoringClient
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Gitlab::CurrentSettings
|
||||
|
||||
included do
|
||||
before_action :validate_ip_whitelisted_or_valid_token!
|
||||
end
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
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
|
||||
|
|
|
@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
before_action :authorize_create_issue!, only: [:new, :create]
|
||||
|
||||
# Allow modify issue
|
||||
before_action :authorize_update_issue!, only: [:edit, :update]
|
||||
before_action :authorize_update_issue!, only: [:edit, :update, :move]
|
||||
|
||||
# Allow create a new branch and empty WIP merge request from current issue
|
||||
before_action :authorize_create_merge_request!, only: [:create_merge_request]
|
||||
|
@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: IssueSerializer.new.represent(@issue)
|
||||
render json: serializer.represent(@issue)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def discussions
|
||||
notes = @issue.notes
|
||||
.inc_relations_for_view
|
||||
.includes(:noteable)
|
||||
.fresh
|
||||
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
|
||||
|
||||
prepare_notes_for_rendering(notes)
|
||||
|
||||
discussions = Discussion.build_collection(notes, @issue)
|
||||
|
||||
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
|
||||
end
|
||||
|
||||
def create
|
||||
create_params = issue_params.merge(spammable_params).merge(
|
||||
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
|
||||
|
@ -128,25 +142,33 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
|
||||
@issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue)
|
||||
|
||||
if params[:move_to_project_id].to_i > 0
|
||||
new_project = Project.find(params[:move_to_project_id])
|
||||
return render_404 unless issue.can_move?(current_user, new_project)
|
||||
|
||||
move_service = Issues::MoveService.new(project, current_user)
|
||||
@issue = move_service.execute(@issue, new_project)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
recaptcha_check_with_fallback { render :edit }
|
||||
end
|
||||
|
||||
format.json do
|
||||
if @issue.valid?
|
||||
render json: IssueSerializer.new.represent(@issue)
|
||||
else
|
||||
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
render_issue_json
|
||||
end
|
||||
end
|
||||
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
render_conflict_response
|
||||
end
|
||||
|
||||
def move
|
||||
params.require(:move_to_project_id)
|
||||
|
||||
if params[:move_to_project_id].to_i > 0
|
||||
new_project = Project.find(params[:move_to_project_id])
|
||||
return render_404 unless issue.can_move?(current_user, new_project)
|
||||
|
||||
@issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
render_issue_json
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -257,6 +279,14 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
return render_404 unless @project.feature_available?(:issues, current_user)
|
||||
end
|
||||
|
||||
def render_issue_json
|
||||
if @issue.valid?
|
||||
render json: serializer.represent(@issue)
|
||||
else
|
||||
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def issue_params
|
||||
params.require(:issue).permit(*issue_params_attributes)
|
||||
end
|
||||
|
@ -287,4 +317,8 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
|
||||
redirect_to new_user_session_path, notice: notice
|
||||
end
|
||||
|
||||
def serializer
|
||||
IssueSerializer.new(current_user: current_user, project: issue.project)
|
||||
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
|
||||
|
|
|
@ -24,7 +24,6 @@ class IssuableFinder
|
|||
include CreatedAtFilter
|
||||
|
||||
NONE = '0'.freeze
|
||||
IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze
|
||||
|
||||
attr_accessor :current_user, :params
|
||||
|
||||
|
@ -68,7 +67,7 @@ class IssuableFinder
|
|||
# grouping and counting within that query.
|
||||
#
|
||||
def count_by_state
|
||||
count_params = params.merge(state: nil, sort: nil, for_counting: true)
|
||||
count_params = params.merge(state: nil, sort: nil)
|
||||
labels_count = label_names.any? ? label_names.count : 1
|
||||
finder = self.class.new(current_user, count_params)
|
||||
counts = Hash.new(0)
|
||||
|
@ -91,16 +90,6 @@ class IssuableFinder
|
|||
execute.find_by!(*params)
|
||||
end
|
||||
|
||||
def state_counter_cache_key
|
||||
cache_key(state_counter_cache_key_components)
|
||||
end
|
||||
|
||||
def clear_caches!
|
||||
state_counter_cache_key_components_permutations.each do |components|
|
||||
Rails.cache.delete(cache_key(components))
|
||||
end
|
||||
end
|
||||
|
||||
def group
|
||||
return @group if defined?(@group)
|
||||
|
||||
|
@ -432,20 +421,4 @@ class IssuableFinder
|
|||
def current_user_related?
|
||||
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
|
||||
end
|
||||
|
||||
def state_counter_cache_key_components
|
||||
opts = params.with_indifferent_access
|
||||
opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY)
|
||||
opts.delete_if { |_, value| value.blank? }
|
||||
|
||||
['issuables_count', klass.to_ability_name, opts.sort]
|
||||
end
|
||||
|
||||
def state_counter_cache_key_components_permutations
|
||||
[state_counter_cache_key_components]
|
||||
end
|
||||
|
||||
def cache_key(components)
|
||||
Digest::SHA1.hexdigest(components.flatten.join('-'))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder
|
|||
project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL
|
||||
end
|
||||
|
||||
# Anonymous users can't see any confidential issues.
|
||||
#
|
||||
# Users without access to see _all_ confidential issues (as in
|
||||
# `user_can_see_all_confidential_issues?`) are more complicated, because they
|
||||
# can see confidential issues where:
|
||||
# 1. They are an assignee.
|
||||
# 2. They are an author.
|
||||
#
|
||||
# That's fine for most cases, but if we're just counting, we need to cache
|
||||
# effectively. If we cached this accurately, we'd have a cache key for every
|
||||
# authenticated user without sufficient access to the project. Instead, when
|
||||
# we are counting, we treat them as if they can't see any confidential issues.
|
||||
#
|
||||
# This does mean the counts may be wrong for those users, but avoids an
|
||||
# explosion in cache keys.
|
||||
def user_cannot_see_confidential_issues?(for_counting: false)
|
||||
def user_cannot_see_confidential_issues?
|
||||
return false if user_can_see_all_confidential_issues?
|
||||
|
||||
current_user.blank? || for_counting || params[:for_counting]
|
||||
end
|
||||
|
||||
def state_counter_cache_key_components
|
||||
extra_components = [
|
||||
user_can_see_all_confidential_issues?,
|
||||
user_cannot_see_confidential_issues?(for_counting: true)
|
||||
]
|
||||
|
||||
super + extra_components
|
||||
end
|
||||
|
||||
def state_counter_cache_key_components_permutations
|
||||
# Ignore the last two, as we'll provide both options for them.
|
||||
components = super.first[0..-3]
|
||||
|
||||
[
|
||||
components + [false, true],
|
||||
components + [true, false]
|
||||
]
|
||||
current_user.blank?
|
||||
end
|
||||
|
||||
def by_assignee(items)
|
||||
|
|
|
@ -202,7 +202,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def support_url
|
||||
current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
|
||||
Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
|
||||
end
|
||||
|
||||
def page_filter_path(options = {})
|
||||
|
@ -303,7 +303,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def show_new_nav?
|
||||
cookies["new_nav"] == "true"
|
||||
true
|
||||
end
|
||||
|
||||
def collapsed_sidebar?
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue