Commit 1797f347 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'ce-to-ee-2018-08-07' into 'master'

CE upstream - 2018-08-07 12:24 UTC

See merge request gitlab-org/gitlab-ee!6822
parents 21b33a7d 0e29a760
...@@ -53,4 +53,8 @@ export default class Autosave { ...@@ -53,4 +53,8 @@ export default class Autosave {
return window.localStorage.removeItem(this.key); return window.localStorage.removeItem(this.key);
} }
dispose() {
this.field.off('input');
}
} }
...@@ -30,6 +30,7 @@ export default { ...@@ -30,6 +30,7 @@ export default {
:render-header="false" :render-header="false"
:render-diff-file="false" :render-diff-file="false"
:always-expanded="true" :always-expanded="true"
:discussions-by-diff-order="true"
/> />
</ul> </ul>
</div> </div>
......
...@@ -189,7 +189,6 @@ export default { ...@@ -189,7 +189,6 @@ export default {
</button> </button>
<a <a
v-if="lineNumber" v-if="lineNumber"
v-once
:data-linenumber="lineNumber" :data-linenumber="lineNumber"
:href="lineHref" :href="lineHref"
> >
......
<script> <script>
import $ from 'jquery';
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue'; import noteForm from '../../notes/components/note_form.vue';
import { getNoteFormData } from '../store/utils'; import { getNoteFormData } from '../store/utils';
import Autosave from '../../autosave'; import autosave from '../../notes/mixins/autosave';
import { DIFF_NOTE_TYPE, NOTE_TYPE } from '../constants'; import { DIFF_NOTE_TYPE } from '../constants';
export default { export default {
components: { components: {
noteForm, noteForm,
}, },
mixins: [autosave],
props: { props: {
diffFileHash: { diffFileHash: {
type: String, type: String,
...@@ -41,28 +41,35 @@ export default { ...@@ -41,28 +41,35 @@ export default {
}, },
mounted() { mounted() {
if (this.isLoggedIn) { if (this.isLoggedIn) {
const noteableData = this.getNoteableData;
const keys = [ const keys = [
NOTE_TYPE, this.noteableData.diff_head_sha,
this.noteableType,
noteableData.id,
noteableData.diff_head_sha,
DIFF_NOTE_TYPE, DIFF_NOTE_TYPE,
noteableData.source_project_id, this.noteableData.source_project_id,
this.line.lineCode, this.line.lineCode,
]; ];
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys); this.initAutoSave(this.noteableData, keys);
} }
}, },
methods: { methods: {
...mapActions('diffs', ['cancelCommentForm']), ...mapActions('diffs', ['cancelCommentForm']),
...mapActions(['saveNote', 'refetchDiscussionById']), ...mapActions(['saveNote', 'refetchDiscussionById']),
handleCancelCommentForm() { handleCancelCommentForm(shouldConfirm, isDirty) {
this.autosave.reset(); if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
// eslint-disable-next-line no-alert
if (!window.confirm(msg)) {
return;
}
}
this.cancelCommentForm({ this.cancelCommentForm({
lineCode: this.line.lineCode, lineCode: this.line.lineCode,
}); });
this.$nextTick(() => {
this.resetAutoSave();
});
}, },
handleSaveNote(note) { handleSaveNote(note) {
const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash); const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
......
...@@ -101,7 +101,6 @@ export default { ...@@ -101,7 +101,6 @@ export default {
class="diff-line-num new_line" class="diff-line-num new_line"
/> />
<td <td
v-once
:class="line.type" :class="line.type"
class="line_content" class="line_content"
v-html="line.richText" v-html="line.richText"
......
...@@ -119,7 +119,6 @@ export default { ...@@ -119,7 +119,6 @@ export default {
class="diff-line-num old_line" class="diff-line-num old_line"
/> />
<td <td
v-once
:id="line.left.lineCode" :id="line.left.lineCode"
:class="parallelViewLeftLineType" :class="parallelViewLeftLineType"
class="line_content parallel left-side" class="line_content parallel left-side"
...@@ -140,7 +139,6 @@ export default { ...@@ -140,7 +139,6 @@ export default {
class="diff-line-num new_line" class="diff-line-num new_line"
/> />
<td <td
v-once
:id="line.right.lineCode" :id="line.right.lineCode"
:class="line.right.type" :class="line.right.type"
class="line_content parallel right-side" class="line_content parallel right-side"
......
import _ from 'underscore'; import _ from 'underscore';
export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; export const placeholderImage =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300; const SCROLL_THRESHOLD = 300;
export default class LazyLoader { export default class LazyLoader {
...@@ -48,7 +49,7 @@ export default class LazyLoader { ...@@ -48,7 +49,7 @@ export default class LazyLoader {
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
// Loading Images which are in the current viewport or close to them // Loading Images which are in the current viewport or close to them
this.lazyImages = this.lazyImages.filter((selectedImage) => { this.lazyImages = this.lazyImages.filter(selectedImage => {
if (selectedImage.getAttribute('data-src')) { if (selectedImage.getAttribute('data-src')) {
const imgBoundRect = selectedImage.getBoundingClientRect(); const imgBoundRect = selectedImage.getBoundingClientRect();
const imgTop = scrollTop + imgBoundRect.top; const imgTop = scrollTop + imgBoundRect.top;
...@@ -66,7 +67,18 @@ export default class LazyLoader { ...@@ -66,7 +67,18 @@ export default class LazyLoader {
} }
static loadImage(img) { static loadImage(img) {
if (img.getAttribute('data-src')) { if (img.getAttribute('data-src')) {
img.setAttribute('src', img.getAttribute('data-src')); let imgUrl = img.getAttribute('data-src');
// Only adding width + height for avatars for now
if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
let targetWidth = null;
if (img.getAttribute('width')) {
targetWidth = img.getAttribute('width');
} else {
targetWidth = img.width;
}
if (targetWidth) imgUrl += `?width=${targetWidth}`;
}
img.setAttribute('src', imgUrl);
img.removeAttribute('data-src'); img.removeAttribute('data-src');
img.classList.remove('lazy'); img.classList.remove('lazy');
img.classList.add('js-lazy-loaded'); img.classList.add('js-lazy-loaded');
......
...@@ -5,19 +5,20 @@ import resolvedSvg from 'icons/_icon_status_success_solid.svg'; ...@@ -5,19 +5,20 @@ import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg'; import nextDiscussionSvg from 'icons/_next_discussion.svg';
import { pluralize } from '../../lib/utils/text_utility'; import { pluralize } from '../../lib/utils/text_utility';
import { scrollToElement } from '../../lib/utils/common_utils'; import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [discussionNavigation],
computed: { computed: {
...mapGetters([ ...mapGetters([
'getUserData', 'getUserData',
'getNoteableData', 'getNoteableData',
'discussionCount', 'discussionCount',
'unresolvedDiscussions', 'firstUnresolvedDiscussionId',
'resolvedDiscussionCount', 'resolvedDiscussionCount',
]), ]),
isLoggedIn() { isLoggedIn() {
...@@ -35,11 +36,6 @@ export default { ...@@ -35,11 +36,6 @@ export default {
resolveAllDiscussionsIssuePath() { resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path; return this.getNoteableData.create_issue_to_resolve_discussions_path;
}, },
firstUnresolvedDiscussionId() {
const item = this.unresolvedDiscussions[0] || {};
return item.id;
},
}, },
created() { created() {
this.resolveSvg = resolveSvg; this.resolveSvg = resolveSvg;
...@@ -50,22 +46,10 @@ export default { ...@@ -50,22 +46,10 @@ export default {
methods: { methods: {
...mapActions(['expandDiscussion']), ...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() { jumpToFirstUnresolvedDiscussion() {
const discussionId = this.firstUnresolvedDiscussionId; const diffTab = window.mrTabs.currentAction === 'diffs';
if (!discussionId) { const discussionId = this.firstUnresolvedDiscussionId(diffTab);
return;
}
const el = document.querySelector(`[data-discussion-id="${discussionId}"]`);
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) { this.jumpToDiscussion(discussionId);
this.expandDiscussion({ discussionId });
scrollToElement(el);
}
}, },
}, },
}; };
......
...@@ -7,7 +7,7 @@ import issuableStateMixin from '../mixins/issuable_state'; ...@@ -7,7 +7,7 @@ import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
export default { export default {
name: 'IssueNoteForm', name: 'NoteForm',
components: { components: {
issueWarning, issueWarning,
markdownField, markdownField,
......
<script> <script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionsSvg from 'icons/_next_discussion.svg'; import nextDiscussionsSvg from 'icons/_next_discussion.svg';
import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import systemNote from '~/vue_shared/components/notes/system_note.vue'; import systemNote from '~/vue_shared/components/notes/system_note.vue';
import { s__ } from '~/locale';
import Flash from '../../flash'; import Flash from '../../flash';
import { SYSTEM_NOTE } from '../constants'; import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -20,6 +20,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder ...@@ -20,6 +20,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder
import autosave from '../mixins/autosave'; import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable'; import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
export default { export default {
...@@ -39,7 +40,7 @@ export default { ...@@ -39,7 +40,7 @@ export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [autosave, noteable, resolvable], mixins: [autosave, noteable, resolvable, discussionNavigation],
props: { props: {
discussion: { discussion: {
type: Object, type: Object,
...@@ -60,6 +61,11 @@ export default { ...@@ -60,6 +61,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
discussionsByDiffOrder: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -74,7 +80,12 @@ export default { ...@@ -74,7 +80,12 @@ export default {
'discussionCount', 'discussionCount',
'resolvedDiscussionCount', 'resolvedDiscussionCount',
'allDiscussions', 'allDiscussions',
'unresolvedDiscussionsIdsByDiff',
'unresolvedDiscussionsIdsByDate',
'unresolvedDiscussions', 'unresolvedDiscussions',
'unresolvedDiscussionsIdsOrdered',
'nextUnresolvedDiscussionId',
'isLastUnresolvedDiscussion',
]), ]),
transformedDiscussion() { transformedDiscussion() {
return { return {
...@@ -125,6 +136,10 @@ export default { ...@@ -125,6 +136,10 @@ export default {
hasMultipleUnresolvedDiscussions() { hasMultipleUnresolvedDiscussions() {
return this.unresolvedDiscussions.length > 1; return this.unresolvedDiscussions.length > 1;
}, },
showJumpToNextDiscussion() {
return this.hasMultipleUnresolvedDiscussions &&
!this.isLastUnresolvedDiscussion(this.discussion.id, this.discussionsByDiffOrder);
},
shouldRenderDiffs() { shouldRenderDiffs() {
const { diffDiscussion, diffFile } = this.transformedDiscussion; const { diffDiscussion, diffFile } = this.transformedDiscussion;
...@@ -144,20 +159,18 @@ export default { ...@@ -144,20 +159,18 @@ export default {
return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; return this.isDiffDiscussion ? '' : 'card discussion-wrapper';
}, },
}, },
mounted() { watch: {
isReplying() {
if (this.isReplying) { if (this.isReplying) {
this.initAutoSave(this.transformedDiscussion); this.$nextTick(() => {
} // Pass an extra key to separate reply and note edit forms
}, this.initAutoSave(this.transformedDiscussion, ['Reply']);
updated() { });
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave(this.transformedDiscussion);
} else { } else {
this.setAutoSave(); this.disposeAutoSave();
}
} }
}, },
},
created() { created() {
this.resolveDiscussionsSvg = resolveDiscussionsSvg; this.resolveDiscussionsSvg = resolveDiscussionsSvg;
this.nextDiscussionsSvg = nextDiscussionsSvg; this.nextDiscussionsSvg = nextDiscussionsSvg;
...@@ -194,16 +207,18 @@ export default { ...@@ -194,16 +207,18 @@ export default {
showReplyForm() { showReplyForm() {
this.isReplying = true; this.isReplying = true;
}, },
cancelReplyForm(shouldConfirm) { cancelReplyForm(shouldConfirm, isDirty) {
if (shouldConfirm && this.$refs.noteForm.isDirty) { if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (!window.confirm('Are you sure you want to cancel creating this comment?')) { if (!window.confirm(msg)) {
return; return;
} }
} }
this.resetAutoSave();
this.isReplying = false; this.isReplying = false;
this.resetAutoSave();
}, },
saveReply(noteText, form, callback) { saveReply(noteText, form, callback) {
const postData = { const postData = {
...@@ -241,21 +256,10 @@ Please check your network connection and try again.`; ...@@ -241,21 +256,10 @@ Please check your network connection and try again.`;
}); });
}, },
jumpToNextDiscussion() { jumpToNextDiscussion() {
const discussionIds = this.allDiscussions.map(d => d.id); const nextId =
const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); this.nextUnresolvedDiscussionId(this.discussion.id, this.discussionsByDiffOrder);
const currentIndex = discussionIds.indexOf(this.discussion.id);
const remainingAfterCurrent = discussionIds.slice(currentIndex + 1);
const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1);
if (nextIndex > -1) { this.jumpToDiscussion(nextId);
const nextId = remainingAfterCurrent[nextIndex];
const el = document.querySelector(`[data-discussion-id="${nextId}"]`);
if (el) {
this.expandDiscussion({ discussionId: nextId });
scrollToElement(el);
}
}
}, },
}, },
}; };
...@@ -397,7 +401,7 @@ Please check your network connection and try again.`; ...@@ -397,7 +401,7 @@ Please check your network connection and try again.`;
</a> </a>
</div> </div>
<div <div
v-if="hasMultipleUnresolvedDiscussions" v-if="showJumpToNextDiscussion"
class="btn-group" class="btn-group"
role="group"> role="group">
<button <button
...@@ -420,7 +424,8 @@ Please check your network connection and try again.`; ...@@ -420,7 +424,8 @@ Please check your network connection and try again.`;
:is-editing="false" :is-editing="false"
save-button-title="Comment" save-button-title="Comment"
@handleFormUpdate="saveReply" @handleFormUpdate="saveReply"
@cancelForm="cancelReplyForm" /> @cancelForm="cancelReplyForm"
/>
<note-signed-out-widget v-if="!canReply" /> <note-signed-out-widget v-if="!canReply" />
</div> </div>
</div> </div>
......
...@@ -4,12 +4,18 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; ...@@ -4,12 +4,18 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
export default { export default {
methods: { methods: {
initAutoSave(noteable) { initAutoSave(noteable, extraKeys = []) {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ let keys = [
'Note', 'Note',
capitalizeFirstCharacter(noteable.noteable_type), capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
noteable.id, noteable.id,
]); ];
if (extraKeys) {
keys = keys.concat(extraKeys);
}
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys);
}, },
resetAutoSave() { resetAutoSave() {
this.autosave.reset(); this.autosave.reset();
...@@ -17,5 +23,8 @@ export default { ...@@ -17,5 +23,8 @@ export default {
setAutoSave() { setAutoSave() {
this.autosave.save(); this.autosave.save();
}, },
disposeAutoSave() {
this.autosave.dispose();
},
}, },
}; };
import { scrollToElement } from '~/lib/utils/common_utils';
export default {
methods: {
jumpToDiscussion(id) {
if (id) {
const activeTab = window.mrTabs.currentAction;
const selector =
activeTab === 'diffs'
? `ul.notes[data-discussion-id="${id}"]`
: `div.discussion[data-discussion-id="${id}"]`;
const el = document.querySelector(selector);
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
if (el) {
this.expandDiscussion({ discussionId: id });
scrollToElement(el);
return true;
}
}
return false;
},
},
};
...@@ -82,6 +82,9 @@ export const allDiscussions = (state, getters) => { ...@@ -82,6 +82,9 @@ export const allDiscussions = (state, getters) => {
return Object.values(resolved).concat(unresolved); return Object.values(resolved).concat(unresolved);
}; };
export const allResolvableDiscussions = (state, getters) =>
getters.allDiscussions.filter(d => !d.individual_note && d.resolvable);
export const resolvedDiscussionsById = state => { export const resolvedDiscussionsById = state => {
const map = {}; const map = {};
...@@ -98,6 +101,51 @@ export const resolvedDiscussionsById = state => { ...@@ -98,6 +101,51 @@ export const resolvedDiscussionsById = state => {
return map; return map;
}; };
// Gets Discussions IDs ordered by the date of their initial note
export const unresolvedDiscussionsIdsByDate = (state, getters) =>
getters.allResolvableDiscussions
.filter(d => !d.resolved)
.sort((a, b) => {
const aDate = new Date(a.notes[0].created_at);
const bDate = new Date(b.notes[0].created_at);
if (aDate < bDate) {
return -1;
}
return aDate === bDate ? 0 : 1;
})
.map(d => d.id);
// Gets Discussions IDs ordered by their position in the diff
//
// Sorts the array of resolvable yet unresolved discussions by
// comparing file names first. If file names are the same, compares
// line numbers.
export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
getters.allResolvableDiscussions
.filter(d => !d.resolved)
.sort((a, b) => {
if (!a.diff_file || !b.diff_file) {
return 0;
}
// Get file names comparison result
const filenameComparison = a.diff_file.file_path.localeCompare(b.diff_file.file_path);
// Get the line numbers, to compare within the same file
const aLines = [a.position.formatter.new_line, a.position.formatter.old_line];
const bLines = [b.position.formatter.new_line, b.position.formatter.old_line];
return filenameComparison < 0 ||
(filenameComparison === 0 &&
// .max() because one of them might be zero (if removed/added)
Math.max(aLines[0], aLines[1]) < Math.max(bLines[0], bLines[1]))
? -1
: 1;
})
.map(d => d.id);
export const resolvedDiscussionCount = (state, getters) => { export const resolvedDiscussionCount = (state, getters) => {
const resolvedMap = getters.resolvedDiscussionsById; const resolvedMap = getters.resolvedDiscussionsById;
...@@ -114,5 +162,42 @@ export const discussionTabCounter = state => { ...@@ -114,5 +162,42 @@ export const discussionTabCounter = state => {
return all.length; return all.length;
}; };
// Returns the list of discussion IDs ordered according to given parameter
// @param {Boolean} diffOrder - is ordered by diff?
export const unresolvedDiscussionsIdsOrdered = (state, getters) => diffOrder => {
if (diffOrder) {
return getters.unresolvedDiscussionsIdsByDiff;
}
return getters.unresolvedDiscussionsIdsByDate;
};
// Checks if a given discussion is the last in the current order (diff or date)
// @param {Boolean} discussionId - id of the discussion
// @param {Boolean} diffOrder - is ordered by diff?
export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, diffOrder) => {
const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
const lastDiscussionId = idsOrdered[idsOrdered.length - 1];
return lastDiscussionId === discussionId;
};
// Gets the ID of the discussion following the one provided, respecting order (diff or date)
// @param {Boolean} discussionId - id of the current discussion
// @param {Boolean} diffOrder - is ordered by diff?
export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
const currentIndex = idsOrdered.indexOf(discussionId);
return idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0];
};
// @param {Boolean} diffOrder - is ordered by diff?
export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
if (diffOrder) {
return getters.unresolvedDiscussionsIdsByDiff[0];
}
return getters.unresolvedDiscussionsIdsByDate[0];
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
<script> <script>
/* This is a re-usable vue component for rendering a user avatar that /* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component. tooltip can be configured by props passed to this component.
...@@ -67,7 +66,9 @@ export default { ...@@ -67,7 +66,9 @@ export default {
// we provide an empty string when we use it inside user avatar link. // we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl // In both cases we should render the defaultAvatarUrl
sanitizedSource() { sanitizedSource() {
return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
return baseSrc;
}, },
resultantSrcAttribute() { resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource; return this.lazy ? placeholderImage : this.sanitizedSource;
......
...@@ -19,7 +19,7 @@ module Avatarable ...@@ -19,7 +19,7 @@ module Avatarable
# We use avatar_path instead of overriding avatar_url because of carrierwave. # We use avatar_path instead of overriding avatar_url because of carrierwave.
# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
avatar_path(only_path: args.fetch(:only_path, true)) || super avatar_path(only_path: args.fetch(:only_path, true), size: args[:size]) || super
end end
def retrieve_upload(identifier, paths) def retrieve_upload(identifier, paths)
...@@ -40,12 +40,13 @@ module Avatarable ...@@ -40,12 +40,13 @@ module Avatarable
end end
end end
def avatar_path(only_path: true) def avatar_path(only_path: true, size: nil)
return unless self[:avatar].present? return unless self[:avatar].present?
asset_host = ActionController::Base.asset_host asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present? use_asset_host = asset_host.present?
use_authentication = respond_to?(:public?) && !public? use_authentication = respond_to?(:public?) && !public?
query_params = size&.nonzero? ? "?width=#{size}" : ""
# Avatars for private and internal groups and projects require authentication to be viewed, # Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host. # which means they can only be served by Rails, on the regular GitLab host.
...@@ -64,7 +65,7 @@ module Avatarable ...@@ -64,7 +65,7 @@ module Avatarable
url_base << gitlab_config.relative_url_root url_base << gitlab_config.relative_url_root
end end
url_base + avatar.local_url url_base + avatar.local_url + query_params
end end
# Path that is persisted in the tracking Upload model. Used to fetch the # Path that is persisted in the tracking Upload model. Used to fetch the
......
...@@ -1095,23 +1095,29 @@ class MergeRequest < ActiveRecord::Base ...@@ -1095,23 +1095,29 @@ class MergeRequest < ActiveRecord::Base
def can_be_reverted?(current_user) def can_be_reverted?(current_user)
return false unless merge_commit return false unless merge_commit
return false unless merged_at
merged_at = metrics&.merged_at
notes_association = notes_with_associations
if merged_at
# It is not guaranteed that Note#created_at will be strictly later than # It is not guaranteed that Note#created_at will be strictly later than
# MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this # MergeRequestMetric#merged_at. Nanoseconds on MySQL may break this
# comparison, as will a HA environment if clocks are not *precisely* # comparison, as will a HA environment if clocks are not *precisely*
# synchronized. Add a minute's leeway to compensate for both possibilities # synchronized. Add a minute's leeway to compensate for both possibilities
cutoff = merged_at - 1.minute cutoff = merged_at - 1.minute
notes_association = notes_association.where('created_at >= ?', cutoff) notes_association = notes_with_associations.where('created_at >= ?', cutoff)
end
!merge_commit.has_been_reverted?(current_user, notes_association) !merge_commit.has_been_reverted?(current_user, notes_association)
end end
def merged_at
strong_memoize(:merged_at) do
next unless merged?
metrics&.merged_at ||
merge_event&.created_at ||
notes.system.reorder(nil).find_by(note: 'merged')&.created_at
end
end
def can_be_cherry_picked? def can_be_cherry_picked?
merge_commit.present? merge_commit.present?
end end
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
= link_to(admin_namespace_project_path(project.namespace, project)) do = link_to(admin_namespace_project_path(project.namespace, project)) do
.dash-project-avatar .dash-project-avatar
.avatar-container.s40 .avatar-container.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40') = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name %span.project-full-name
%span.namespace-name %span.namespace-name
- if project.namespace - if project.namespace
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
.context-header .context-header
= link_to project_path(@project), title: @project.name do = link_to project_path(@project), title: @project.name do
.avatar-container.s40.project-avatar .avatar-container.s40.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile') = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title .sidebar-context-title
= @project.name = @project.name
%ul.sidebar-top-level-items %ul.sidebar-top-level-items
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
.project-home-panel.text-center{ class: ("empty-project" if empty_repo) } .project-home-panel.text-center{ class: ("empty-project" if empty_repo) }
.limit-container-width{ class: container_class } .limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar .avatar-container.s70.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile') = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile', width: 70, height: 70)
%h1.project-title.qa-project-name %h1.project-title.qa-project-name
= @project.name = @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
......
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
.form-group .form-group
- if @project.avatar? - if @project.avatar?
.avatar-container.s160.append-bottom-15 .avatar-container.s160.append-bottom-15
= project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160') = project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- if @project.avatar_in_git - if @project.avatar_in_git
%p.light %p.light
= _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
- if project.creator && use_creator_avatar - if project.creator && use_creator_avatar
= image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:'' = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else - else
= project_icon(project, alt: '', class: 'avatar project-avatar s40') = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
.project-details .project-details
%h3.prepend-top-0.append-bottom-0 %h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: 'text-plain' do = link_to project_path(project), class: 'text-plain' do
......
---
title: UX improvements to top nav search bar
merge_request: 20537
author:
type: changed
---
title: Fix the UI for listing system-level labels
merge_request:
author:
type: fixed
---
title: Fix rendering of the context lines in MR diffs page.
merge_request: 20968
author:
type: fixed
---
title: Fix autosave and ESC confirmation issues for MR discussions.
merge_request: 20968
author:
type: fixed
---
title: Fix navigation to First and Next discussion on MR Changes tab.
merge_request: 20968
author:
type: fixed
---
title: Update git rerere link in docs
merge_request: 21060
author: gfyoung
type: other
---
title: Avoid N+1 on MRs page when metrics merging date cannot be found
merge_request: 21053
author:
type: performance
...@@ -63,7 +63,7 @@ Gitaly network traffic is unencrypted so you should use a firewall to ...@@ -63,7 +63,7 @@ Gitaly network traffic is unencrypted so you should use a firewall to
restrict access to your Gitaly server. restrict access to your Gitaly server.
Below we describe how to configure a Gitaly server at address Below we describe how to configure a Gitaly server at address
`gitaly.internal:9999` with secret token `abc123secret`. We assume `gitaly.internal:8075` with secret token `abc123secret`. We assume
your GitLab installation has two repository storages, `default` and your GitLab installation has two repository storages, `default` and
`storage1`. `storage1`.
...@@ -108,8 +108,30 @@ Omnibus installations: ...@@ -108,8 +108,30 @@ Omnibus installations:
```ruby ```ruby
# /etc/gitlab/gitlab.rb # /etc/gitlab/gitlab.rb
gitaly['listen_addr'] = '0.0.0.0:9999'
# Avoid running unnecessary services on the gitaly server
postgresql['enable'] = false
redis['enable'] = false
nginx['enable'] = false
prometheus['enable'] = false
unicorn['enable'] = false
sidekiq['enable'] = false
gitlab_workhorse['enable'] = false
# Prevent database connections during 'gitlab-ctl reconfigure'
gitlab_rails['rake_cache_clear'] = false
gitlab_rails['auto_migrate'] = false
# Configure the gitlab-shell API callback URL. Without this, `git push` will
# fail. This can be your 'front door' GitLab URL or an internal load
# balancer.
gitlab_rails['internal_api_url'] = 'https://gitlab.example.com'
# Make Gitaly accept connections on all network interfaces. You must use
# firewalls to restrict access to this address/port.
gitaly['listen_addr'] = "0.0.0.0:8075"
gitaly['auth_token'] = 'abc123secret' gitaly['auth_token'] = 'abc123secret'
gitaly['storage'] = [ gitaly['storage'] = [
{ 'name' => 'default', 'path' => '/path/to/default/repositories' }, { 'name' => 'default', 'path' => '/path/to/default/repositories' },
{ 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' }, { 'name' => 'storage1', 'path' => '/path/to/storage1/repositories' },
...@@ -120,7 +142,7 @@ Source installations: ...@@ -120,7 +142,7 @@ Source installations:
```toml ```toml
# /home/git/gitaly/config.toml # /home/git/gitaly/config.toml
listen_addr = '0.0.0.0:9999' listen_addr = '0.0.0.0:8075'
[auth] [auth]
token = 'abc123secret' token = 'abc123secret'
...@@ -146,7 +168,7 @@ server from reaching the Gitaly server then all Gitaly requests will ...@@ -146,7 +168,7 @@ server from reaching the Gitaly server then all Gitaly requests will
fail. fail.
We assume that your Gitaly server can be reached at We assume that your Gitaly server can be reached at
`gitaly.internal:9999` from your GitLab server, and that your GitLab `gitaly.internal:8075` from your GitLab server, and that your GitLab
NFS shares are mounted at `/mnt/gitlab/default` and NFS shares are mounted at `/mnt/gitlab/default` and
`/mnt/gitlab/storage1` respectively. `/mnt/gitlab/storage1` respectively.
...@@ -155,8 +177,8 @@ Omnibus installations: ...@@ -155,8 +177,8 @@ Omnibus installations:
```ruby ```ruby
# /etc/gitlab/gitlab.rb # /etc/gitlab/gitlab.rb
git_data_dirs({ git_data_dirs({
'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitlab.internal:9999' }, 'default' => { 'path' => '/mnt/gitlab/default', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitlab.internal:9999' }, 'storage1' => { 'path' => '/mnt/gitlab/storage1', 'gitaly_address' => 'tcp://gitaly.internal:8075' },
}) })
gitlab_rails['gitaly_token'] = 'abc123secret' gitlab_rails['gitaly_token'] = 'abc123secret'
...@@ -171,10 +193,10 @@ gitlab: ...@@ -171,10 +193,10 @@ gitlab:
storages: storages:
default: default:
path: /mnt/gitlab/default/repositories path: /mnt/gitlab/default/repositories
gitaly_address: tcp://gitlab.internal:9999 gitaly_address: tcp://gitaly.internal:8075
storage1: storage1:
path: /mnt/gitlab/storage1/repositories path: /mnt/gitlab/storage1/repositories
gitaly_address: tcp://gitlab.internal:9999 gitaly_address: tcp://gitaly.internal:8075
gitaly: gitaly:
token: 'abc123secret' token: 'abc123secret'
......
# Consider using SSH certificates instead of, or in addition to this # Fast lookup of authorized SSH keys in the database
This document describes a drop-in replacement for the NOTE: **Note:** This document describes a drop-in replacement for the
`authorized_keys` file for normal (non-deploy key) users. Consider `authorized_keys` file for normal (non-deploy key) users. Consider
using [ssh certificates](ssh_certificates.md), they are even faster, using [ssh certificates](ssh_certificates.md), they are even faster,
but are not is not a drop-in replacement. but are not a drop-in replacement.
# Fast lookup of authorized SSH keys in the database
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in > [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/1631) in
> [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3. > [GitLab Starter](https://about.gitlab.com/gitlab-ee) 9.3.
......
...@@ -100,7 +100,7 @@ Notes: ...@@ -100,7 +100,7 @@ Notes:
number of times you have to resolve conflicts. number of times you have to resolve conflicts.
- Please remember to - Please remember to
[always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts). [always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts).
- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) - You can use [`git rerere`](https://git-scm.com/docs/git-rerere)
to avoid resolving the same conflicts multiple times. to avoid resolving the same conflicts multiple times.
### Cherry-picking from CE to EE ### Cherry-picking from CE to EE
......
...@@ -30,6 +30,7 @@ You can edit your account settings by navigating from the up-right corner menu b ...@@ -30,6 +30,7 @@ You can edit your account settings by navigating from the up-right corner menu b
From there, you can: From there, you can:
- Update your personal information - Update your personal information
- Set a [custom status](#current-status) for your profile
- Manage [2FA](account/two_factor_authentication.md) - Manage [2FA](account/two_factor_authentication.md)
- Change your username and [delete your account](account/delete_account.md) - Change your username and [delete your account](account/delete_account.md)
- Manage applications that can - Manage applications that can
...@@ -90,6 +91,27 @@ To enable private profile: ...@@ -90,6 +91,27 @@ To enable private profile:
NOTE: **Note:** NOTE: **Note:**
You and GitLab admins can see your the abovementioned information on your profile even if it is private. You and GitLab admins can see your the abovementioned information on your profile even if it is private.
## Current status
> Introduced in GitLab 11.2.
You can provide a custom status message for your user profile along with an emoji that describes it.
This may be helpful when you are out of office or otherwise not available.
Other users can then take your status into consideration when responding to your issues or assigning work to you.
Please be aware that your status is publicly visible even if your [profile is private](#private-profile).
To set your current status:
1. Navigate to your personal [profile settings](#profile-settings).
1. In the text field below `Your status`, enter your status message.
1. Select an emoji from the dropdown if you like.
1. Hit **Update profile settings**.
Status messages are restricted to 100 characters of plain text.
They may however contain emoji codes such as `I'm on vacation :palm_tree:`.
You can also set your current status [using the API](../../api/users.md#user-status).
## Troubleshooting ## Troubleshooting
### Why do I keep getting signed out? ### Why do I keep getting signed out?
......
...@@ -21,7 +21,7 @@ describe('epicHeader', () => { ...@@ -21,7 +21,7 @@ describe('epicHeader', () => {
}); });
it('should render author avatar', () => { it('should render author avatar', () => {
expect(vm.$el.querySelector('img').src).toEqual(author.src); expect(vm.$el.querySelector('img').src).toEqual(`${author.src}?width=24`);
}); });
it('should render author name', () => { it('should render author name', () => {
...@@ -29,7 +29,9 @@ describe('epicHeader', () => { ...@@ -29,7 +29,9 @@ describe('epicHeader', () => {
}); });
it('should render username tooltip', () => { it('should render username tooltip', () => {
expect(vm.$el.querySelector('.user-avatar-link span').dataset.originalTitle).toEqual(author.username); expect(vm.$el.querySelector('.user-avatar-link span').dataset.originalTitle).toEqual(
author.username,
);
}); });
describe('canDelete', () => { describe('canDelete', () => {
...@@ -37,7 +39,7 @@ describe('epicHeader', () => { ...@@ -37,7 +39,7 @@ describe('epicHeader', () => {
expect(vm.$el.querySelector('.btn-remove')).toBeNull(); expect(vm.$el.querySelector('.btn-remove')).toBeNull();
}); });
it('should show loading button if canDelete', (done) => { it('should show loading button if canDelete', done => {
vm.canDelete = true; vm.canDelete = true;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-remove')).toBeDefined(); expect(vm.$el.querySelector('.btn-remove')).toBeDefined();
...@@ -49,7 +51,7 @@ describe('epicHeader', () => { ...@@ -49,7 +51,7 @@ describe('epicHeader', () => {
describe('delete epic', () => { describe('delete epic', () => {
let deleteEpic; let deleteEpic;
beforeEach((done) => { beforeEach(done => {
deleteEpic = jasmine.createSpy(); deleteEpic = jasmine.createSpy();
spyOn(window, 'confirm').and.returnValue(true); spyOn(window, 'confirm').and.returnValue(true);
vm.canDelete = true; vm.canDelete = true;
......
...@@ -4641,6 +4641,9 @@ msgstr "" ...@@ -4641,6 +4641,9 @@ msgstr ""
msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token." msgid "Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token."
msgstr "" msgstr ""
msgid "Notes|Are you sure you want to cancel creating this comment?"
msgstr ""
msgid "Notification events" msgid "Notification events"
msgstr "" msgstr ""
......
...@@ -342,8 +342,9 @@ describe 'Merge request > User resolves diff notes and discussions', :js do ...@@ -342,8 +342,9 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end end
end end
it 'shows jump to next discussion button' do it 'shows jump to next discussion button, apart from the last one' do
expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn')) expect(page).to have_selector('.discussion-reply-holder', count: 2)
expect(page).to have_selector('.discussion-reply-holder .discussion-next-btn', count: 1)
end end
it 'displays next discussion even if hidden' do it 'displays next discussion even if hidden' do
......
...@@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do ...@@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do
visit user_path(user) visit user_path(user)
expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"])) expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=90"]))
# Cheating here to verify something that isn't user-facing, but is important # Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist expect(user.reload.avatar.file).to exist
......
...@@ -59,12 +59,10 @@ describe('Autosave', () => { ...@@ -59,12 +59,10 @@ describe('Autosave', () => {
Autosave.prototype.restore.call(autosave); Autosave.prototype.restore.call(autosave);
expect( expect(field.trigger).toHaveBeenCalled();
field.trigger,
).toHaveBeenCalled();
}); });
it('triggers native event', (done) => { it('triggers native event', done => {
autosave.field.get(0).addEventListener('change', () => { autosave.field.get(0).addEventListener('change', () => {
done(); done();
}); });
...@@ -81,9 +79,7 @@ describe('Autosave', () => { ...@@ -81,9 +79,7 @@ describe('Autosave', () => {
it('does not trigger event', () => { it('does not trigger event', () => {
spyOn(field, 'trigger').and.callThrough(); spyOn(field, 'trigger').and.callThrough();
expect( expect(field.trigger).not.toHaveBeenCalled();
field.trigger,
).not.toHaveBeenCalled();
}); });
}); });
}); });
......
...@@ -72,109 +72,100 @@ describe('Issue card component', () => { ...@@ -72,109 +72,100 @@ describe('Issue card component', () => {
}); });
it('renders issue title', () => { it('renders issue title', () => {
expect( expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title);
component.$el.querySelector('.board-card-title').textContent,
).toContain(issue.title);
}); });
it('includes issue base in link', () => { it('includes issue base in link', () => {
expect( expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain(
component.$el.querySelector('.board-card-title a').getAttribute('href'), '/test',
).toContain('/test'); );
}); });
it('includes issue title on link', () => { it('includes issue title on link', () => {
expect( expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe(
component.$el.querySelector('.board-card-title a').getAttribute('title'), issue.title,
).toBe(issue.title); );
}); });
it('does not render confidential icon', () => { it('does not render confidential icon', () => {
expect( expect(component.$el.querySelector('.fa-eye-flash')).toBeNull();
component.$el.querySelector('.fa-eye-flash'),
).toBeNull();
}); });
it('renders confidential icon', (done) => { it('renders confidential icon', done => {
component.issue.confidential = true; component.issue.confidential = true;
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(component.$el.querySelector('.confidential-icon')).not.toBeNull();
component.$el.querySelector('.confidential-icon'),
).not.toBeNull();
done(); done();
}); });
}); });
it('renders issue ID with #', () => { it('renders issue ID with #', () => {
expect( expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`);
component.$el.querySelector('.board-card-number').textContent,
).toContain(`#${issue.id}`);
}); });
describe('assignee', () => { describe('assignee', () => {
it('does not render assignee', () => { it('does not render assignee', () => {
expect( expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull();
component.$el.querySelector('.board-card-assignee .avatar'),
).toBeNull();
}); });
describe('exists', () => { describe('exists', () => {
beforeEach((done) => { beforeEach(done => {
component.issue.assignees = [user]; component.issue.assignees = [user];
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
it('renders assignee', () => { it('renders assignee', () => {
expect( expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull();
component.$el.querySelector('.board-card-assignee .avatar'),
).not.toBeNull();
}); });
it('sets title', () => { it('sets title', () => {
expect( expect(
component.$el.querySelector('.board-card-assignee img').getAttribute('data-original-title'), component.$el
.querySelector('.board-card-assignee img')
.getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`); ).toContain(`Assigned to ${user.name}`);
}); });
it('sets users path', () => { it('sets users path', () => {
expect( expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe(
component.$el.querySelector('.board-card-assignee a').getAttribute('href'), '/test',
).toBe('/test'); );
}); });
it('renders avatar', () => { it('renders avatar', () => {
expect( expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
component.$el.querySelector('.board-card-assignee img'),
).not.toBeNull();
}); });
}); });
describe('assignee default avatar', () => { describe('assignee default avatar', () => {
beforeEach((done) => { beforeEach(done => {
component.issue.assignees = [new ListAssignee({ component.issue.assignees = [
new ListAssignee(
{
id: 1, id: 1,
name: 'testing 123', name: 'testing 123',
username: 'test', username: 'test',
}, 'default_avatar')]; },
'default_avatar',
),
];
Vue.nextTick(done); Vue.nextTick(done);
}); });
it('displays defaults avatar if users avatar is null', () => { it('displays defaults avatar if users avatar is null', () => {
expect( expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
component.$el.querySelector('.board-card-assignee img'), expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe(
).not.toBeNull(); 'default_avatar?width=20',
expect( );
component.$el.querySelector('.board-card-assignee img').getAttribute('src'),
).toBe('default_avatar');
}); });
}); });
}); });
describe('multiple assignees', () => { describe('multiple assignees', () => {
beforeEach((done) => { beforeEach(done => {
component.issue.assignees = [ component.issue.assignees = [
user, user,
new ListAssignee({ new ListAssignee({
...@@ -194,7 +185,8 @@ describe('Issue card component', () => { ...@@ -194,7 +185,8 @@ describe('Issue card component', () => {
name: 'user4', name: 'user4',
username: 'user4', username: 'user4',
avatar: 'test_image', avatar: 'test_image',
})]; }),
];
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
...@@ -204,26 +196,30 @@ describe('Issue card component', () => { ...@@ -204,26 +196,30 @@ describe('Issue card component', () => {
}); });
describe('more than four assignees', () => { describe('more than four assignees', () => {
beforeEach((done) => { beforeEach(done => {
component.issue.assignees.push(new ListAssignee({ component.issue.assignees.push(
new ListAssignee({
id: 5, id: 5,
name: 'user5', name: 'user5',
username: 'user5', username: 'user5',
avatar: 'test_image', avatar: 'test_image',
})); }),
);
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
it('renders more avatar counter', () => { it('renders more avatar counter', () => {
expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('+2'); expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
).toEqual('+2');
}); });
it('renders three assignees', () => { it('renders three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
}); });
it('renders 99+ avatar counter', (done) => { it('renders 99+ avatar counter', done => {
for (let i = 5; i < 104; i += 1) { for (let i = 5; i < 104; i += 1) {
const u = new ListAssignee({ const u = new ListAssignee({
id: i, id: i,
...@@ -235,7 +231,9 @@ describe('Issue card component', () => { ...@@ -235,7 +231,9 @@ describe('Issue card component', () => {
} }
Vue.nextTick(() => { Vue.nextTick(() => {
expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('99+'); expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
).toEqual('99+');
done(); done();
}); });
}); });
...@@ -243,59 +241,51 @@ describe('Issue card component', () => { ...@@ -243,59 +241,51 @@ describe('Issue card component', () => {
}); });
describe('labels', () => { describe('labels', () => {
beforeEach((done) => { beforeEach(done => {
component.issue.addLabel(label1); component.issue.addLabel(label1);
Vue.nextTick(() => done()); Vue.nextTick(() => done());
}); });
it('renders list label', () => { it('renders list label', () => {
expect( expect(component.$el.querySelectorAll('.badge').length).toBe(2);
component.$el.querySelectorAll('.badge').length,
).toBe(2);
}); });
it('renders label', () => { it('renders label', () => {
const nodes = []; const nodes = [];
component.$el.querySelectorAll('.badge').forEach((label) => { component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.getAttribute('data-original-title')); nodes.push(label.getAttribute('data-original-title'));
}); });
expect( expect(nodes.includes(label1.description)).toBe(true);
nodes.includes(label1.description),
).toBe(true);
}); });
it('sets label description as title', () => { it('sets label description as title', () => {
expect( expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain(
component.$el.querySelector('.badge').getAttribute('data-original-title'), label1.description,
).toContain(label1.description); );
}); });
it('sets background color of button', () => { it('sets background color of button', () => {
const nodes = []; const nodes = [];
component.$el.querySelectorAll('.badge').forEach((label) => { component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.style.backgroundColor); nodes.push(label.style.backgroundColor);
}); });
expect( expect(nodes.includes(label1.color)).toBe(true);
nodes.includes(label1.color),
).toBe(true);
}); });
it('does not render label if label does not have an ID', (done) => { it('does not render label if label does not have an ID', done => {
component.issue.addLabel(new ListLabel({ component.issue.addLabel(
new ListLabel({
title: 'closed', title: 'closed',
})); }),
);
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect( expect(component.$el.querySelectorAll('.badge').length).toBe(2);
component.$el.querySelectorAll('.badge').length, expect(component.$el.textContent).not.toContain('closed');
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
done(); done();
}) })
......
...@@ -3,6 +3,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; ...@@ -3,6 +3,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
import store from '~/mr_notes/stores'; import store from '~/mr_notes/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file'; import diffFileMockData from '../mock_data/diff_file';
import { noteableDataMock } from '../../notes/mock_data';
describe('DiffLineNoteForm', () => { describe('DiffLineNoteForm', () => {
let component; let component;
...@@ -21,10 +22,9 @@ describe('DiffLineNoteForm', () => { ...@@ -21,10 +22,9 @@ describe('DiffLineNoteForm', () => {
noteTargetLine: diffLines[0], noteTargetLine: diffLines[0],
}); });
Object.defineProperty(component, 'isLoggedIn', { Object.defineProperties(component, {
get() { noteableData: { value: noteableDataMock },
return true; isLoggedIn: { value: true },
},
}); });
component.$mount(); component.$mount();
...@@ -32,13 +32,38 @@ describe('DiffLineNoteForm', () => { ...@@ -32,13 +32,38 @@ describe('DiffLineNoteForm', () => {
describe('methods', () => { describe('methods', () => {
describe('handleCancelCommentForm', () => { describe('handleCancelCommentForm', () => {
it('should call cancelCommentForm with lineCode', () => { it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => {
spyOn(window, 'confirm').and.returnValue(false);
component.handleCancelCommentForm(true, true);
expect(window.confirm).toHaveBeenCalled();
});
it('should ask for confirmation when one of the params false', () => {
spyOn(window, 'confirm').and.returnValue(false);
component.handleCancelCommentForm(true, false);
expect(window.confirm).not.toHaveBeenCalled();
component.handleCancelCommentForm(false, true);
expect(window.confirm).not.toHaveBeenCalled();
});
it('should call cancelCommentForm with lineCode', done => {
spyOn(window, 'confirm');
spyOn(component, 'cancelCommentForm'); spyOn(component, 'cancelCommentForm');
spyOn(component, 'resetAutoSave');
component.handleCancelCommentForm(); component.handleCancelCommentForm();
expect(window.confirm).not.toHaveBeenCalled();
component.$nextTick(() => {
expect(component.cancelCommentForm).toHaveBeenCalledWith({ expect(component.cancelCommentForm).toHaveBeenCalledWith({
lineCode: diffLines[0].lineCode, lineCode: diffLines[0].lineCode,
}); });
expect(component.resetAutoSave).toHaveBeenCalled();
done();
});
}); });
}); });
...@@ -66,7 +91,7 @@ describe('DiffLineNoteForm', () => { ...@@ -66,7 +91,7 @@ describe('DiffLineNoteForm', () => {
describe('mounted', () => { describe('mounted', () => {
it('should init autosave', () => { it('should init autosave', () => {
const key = 'autosave/Note/issue///DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1'; const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1';
expect(component.autosave).toBeDefined(); expect(component.autosave).toBeDefined();
expect(component.autosave.key).toEqual(key); expect(component.autosave.key).toEqual(key);
......
...@@ -46,7 +46,7 @@ describe('DiscussionCounter component', () => { ...@@ -46,7 +46,7 @@ describe('DiscussionCounter component', () => {
discussions, discussions,
}); });
setFixtures(` setFixtures(`
<div data-discussion-id="${firstDiscussionId}"></div> <div class="discussion" data-discussion-id="${firstDiscussionId}"></div>
`); `);
vm.jumpToFirstUnresolvedDiscussion(); vm.jumpToFirstUnresolvedDiscussion();
......
...@@ -14,6 +14,7 @@ describe('noteable_discussion component', () => { ...@@ -14,6 +14,7 @@ describe('noteable_discussion component', () => {
preloadFixtures(discussionWithTwoUnresolvedNotes); preloadFixtures(discussionWithTwoUnresolvedNotes);
beforeEach(() => { beforeEach(() => {
window.mrTabs = {};
store = createStore(); store = createStore();
store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock); store.dispatch('setNotesData', notesDataMock);
...@@ -46,12 +47,17 @@ describe('noteable_discussion component', () => { ...@@ -46,12 +47,17 @@ describe('noteable_discussion component', () => {
it('should toggle reply form', done => { it('should toggle reply form', done => {
vm.$el.querySelector('.js-vue-discussion-reply').click(); vm.$el.querySelector('.js-vue-discussion-reply').click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$refs.noteForm).not.toBeNull();
expect(vm.isReplying).toEqual(true); expect(vm.isReplying).toEqual(true);
// There is a watcher for `isReplying` which will init autosave in the next tick
Vue.nextTick(() => {
expect(vm.$refs.noteForm).not.toBeNull();
done(); done();
}); });
}); });
});
it('does not render jump to discussion button', () => { it('does not render jump to discussion button', () => {
expect( expect(
...@@ -101,33 +107,29 @@ describe('noteable_discussion component', () => { ...@@ -101,33 +107,29 @@ describe('noteable_discussion component', () => {
describe('methods', () => { describe('methods', () => {
describe('jumpToNextDiscussion', () => { describe('jumpToNextDiscussion', () => {
it('expands next unresolved discussion', () => { it('expands next unresolved discussion', done => {
const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion2.resolved = false;
discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
window.mrTabs.currentAction = 'show';
Vue.nextTick()
.then(() => {
spyOn(vm, 'expandDiscussion').and.stub(); spyOn(vm, 'expandDiscussion').and.stub();
const discussions = [
discussionMock, const nextDiscussionId = discussion2.id;
{
...discussionMock,
id: discussionMock.id + 1,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }],
},
{
...discussionMock,
id: discussionMock.id + 2,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }],
},
];
const nextDiscussionId = discussionMock.id + 2;
store.replaceState({
...store.state,
discussions,
});
setFixtures(` setFixtures(`
<div data-discussion-id="${nextDiscussionId}"></div> <div class="discussion" data-discussion-id="${nextDiscussionId}"></div>
`); `);
vm.jumpToNextDiscussion(); vm.jumpToNextDiscussion();
expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId }); expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: nextDiscussionId });
})
.then(done)
.catch(done.fail);
}); });
}); });
}); });
......
...@@ -1168,3 +1168,87 @@ export const collapsedSystemNotes = [ ...@@ -1168,3 +1168,87 @@ export const collapsedSystemNotes = [
diff_discussion: false, diff_discussion: false,
}, },
]; ];
export const discussion1 = {
id: 'abc1',
resolvable: true,
resolved: false,
diff_file: {
file_path: 'about.md',
},
position: {
formatter: {
new_line: 50,
old_line: null,
},
},
notes: [
{
created_at: '2018-07-04T16:25:41.749Z',
},
],
};
export const resolvedDiscussion1 = {
id: 'abc1',
resolvable: true,
resolved: true,
diff_file: {
file_path: 'about.md',
},
position: {
formatter: {
new_line: 50,
old_line: null,
},
},
notes: [
{
created_at: '2018-07-04T16:25:41.749Z',
},
],
};
export const discussion2 = {
id: 'abc2',
resolvable: true,
resolved: false,
diff_file: {
file_path: 'README.md',
},
position: {
formatter: {
new_line: null,
old_line: 20,
},
},
notes: [
{
created_at: '2018-07-04T12:05:41.749Z',
},
],
};
export const discussion3 = {
id: 'abc3',
resolvable: true,
resolved: false,
diff_file: {
file_path: 'README.md',
},
position: {
formatter: {
new_line: 21,
old_line: null,
},
},
notes: [
{
created_at: '2018-07-05T17:25:41.749Z',
},
],
};
export const unresolvableDiscussion = {
resolvable: false,
};
...@@ -5,6 +5,11 @@ import { ...@@ -5,6 +5,11 @@ import {
noteableDataMock, noteableDataMock,
individualNote, individualNote,
collapseNotesMock, collapseNotesMock,
discussion1,
discussion2,
discussion3,
resolvedDiscussion1,
unresolvableDiscussion,
} from '../mock_data'; } from '../mock_data';
const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
...@@ -109,4 +114,154 @@ describe('Getters Notes Store', () => { ...@@ -109,4 +114,154 @@ describe('Getters Notes Store', () => {
expect(getters.isNotesFetched(state)).toBeFalsy(); expect(getters.isNotesFetched(state)).toBeFalsy();
}); });
}); });
describe('allResolvableDiscussions', () => {
it('should return only resolvable discussions in same order', () => {
const localGetters = {
allDiscussions: [
discussion3,
unresolvableDiscussion,
discussion1,
unresolvableDiscussion,
discussion2,
],
};
expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([
discussion3,
discussion1,
discussion2,
]);
});
it('should return empty array if there are no resolvable discussions', () => {
const localGetters = {
allDiscussions: [unresolvableDiscussion, unresolvableDiscussion],
};
expect(getters.allResolvableDiscussions(state, localGetters)).toEqual([]);
});
});
describe('unresolvedDiscussionsIdsByDiff', () => {
it('should return all discussions IDs in diff order', () => {
const localGetters = {
allResolvableDiscussions: [discussion3, discussion1, discussion2],
};
expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([
'abc1',
'abc2',
'abc3',
]);
});
it('should return empty array if all discussions have been resolved', () => {
const localGetters = {
allResolvableDiscussions: [resolvedDiscussion1],
};
expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]);
});
});
describe('unresolvedDiscussionsIdsByDate', () => {
it('should return all discussions in date ascending order', () => {
const localGetters = {
allResolvableDiscussions: [discussion3, discussion1, discussion2],
};
expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([
'abc2',
'abc1',
'abc3',
]);
});
it('should return empty array if all discussions have been resolved', () => {
const localGetters = {
allResolvableDiscussions: [resolvedDiscussion1],
};
expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([]);
});
});
describe('unresolvedDiscussionsIdsOrdered', () => {
const localGetters = {
unresolvedDiscussionsIdsByDate: ['123', '456'],
unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
};
it('should return IDs ordered by diff when diffOrder param is true', () => {
expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(true)).toEqual([
'abc',
'def',
]);
});
it('should return IDs ordered by date when diffOrder param is not true', () => {
expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(false)).toEqual([
'123',
'456',
]);
expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(undefined)).toEqual([
'123',
'456',
]);
});
});
describe('isLastUnresolvedDiscussion', () => {
const localGetters = {
unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
};
it('should return true if the discussion id provided is the last', () => {
expect(getters.isLastUnresolvedDiscussion(state, localGetters)('789')).toBe(true);
});
it('should return false if the discussion id provided is not the last', () => {
expect(getters.isLastUnresolvedDiscussion(state, localGetters)('123')).toBe(false);
expect(getters.isLastUnresolvedDiscussion(state, localGetters)('456')).toBe(false);
});
});
describe('nextUnresolvedDiscussionId', () => {
const localGetters = {
unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
};
it('should return the ID of the discussion after the ID provided', () => {
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('123')).toBe('456');
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('456')).toBe('789');
expect(getters.nextUnresolvedDiscussionId(state, localGetters)('789')).toBe(undefined);
});
});
describe('firstUnresolvedDiscussionId', () => {
const localGetters = {
unresolvedDiscussionsIdsByDate: ['123', '456'],
unresolvedDiscussionsIdsByDiff: ['abc', 'def'],
};
it('should return the first discussion id by diff when diffOrder param is true', () => {
expect(getters.firstUnresolvedDiscussionId(state, localGetters)(true)).toBe('abc');
});
it('should return the first discussion id by date when diffOrder param is not true', () => {
expect(getters.firstUnresolvedDiscussionId(state, localGetters)(false)).toBe('123');
expect(getters.firstUnresolvedDiscussionId(state, localGetters)(undefined)).toBe('123');
});
it('should be falsy if all discussions are resolved', () => {
const localGettersFalsy = {
unresolvedDiscussionsIdsByDiff: [],
unresolvedDiscussionsIdsByDate: [],
};
expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(true)).toBeFalsy();
expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy();
});
});
}); });
...@@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => { ...@@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => {
}, },
}).$mount(); }).$mount();
expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo'); expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual(
'foo',
);
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1'); expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
}); });
...@@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => { ...@@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => {
const image = component.$el.querySelector('.js-pipeline-url-user img'); const image = component.$el.querySelector('.js-pipeline-url-user img');
expect( expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'), mockData.pipeline.user.web_url,
).toEqual(mockData.pipeline.user.web_url); );
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name); expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url); expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
}); });
it('should render "API" when no user is provided', () => { it('should render "API" when no user is provided', () => {
...@@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => { ...@@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => {
}).$mount(); }).$mount();
expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest'); expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest');
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid'); expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain(
'yaml invalid',
);
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck'); expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
}); });
...@@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => { ...@@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => {
}, },
}).$mount(); }).$mount();
expect( expect(component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim()).toEqual(
component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim(), 'Auto DevOps',
).toEqual('Auto DevOps'); );
}); });
it('should render error badge when pipeline has a failure reason set', () => { it('should render error badge when pipeline has a failure reason set', () => {
...@@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => { ...@@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => {
}).$mount(); }).$mount();
expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error'); expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error');
expect(component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title')).toContain('some reason'); expect(
component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title'),
).toContain('some reason');
}); });
}); });
...@@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => { ...@@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => {
userDataMock.path, userDataMock.path,
); );
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual( expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
userDataMock.avatar_url, `${userDataMock.avatar_url}?width=40`,
); );
}); });
}); });
......
...@@ -12,7 +12,7 @@ const DEFAULT_PROPS = { ...@@ -12,7 +12,7 @@ const DEFAULT_PROPS = {
tooltipPlacement: 'bottom', tooltipPlacement: 'bottom',
}; };
describe('User Avatar Image Component', function () { describe('User Avatar Image Component', function() {
let vm; let vm;
let UserAvatarImage; let UserAvatarImage;
...@@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () { ...@@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () {
UserAvatarImage = Vue.extend(userAvatarImage); UserAvatarImage = Vue.extend(userAvatarImage);
}); });
describe('Initialization', function () { describe('Initialization', function() {
beforeEach(function () { beforeEach(function() {
vm = mountComponent(UserAvatarImage, { vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS, ...DEFAULT_PROPS,
}).$mount(); }).$mount();
}); });
it('should return a defined Vue component', function () { it('should return a defined Vue component', function() {
expect(vm).toBeDefined(); expect(vm).toBeDefined();
}); });
it('should have <img> as a child element', function () { it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG'); expect(vm.$el.tagName).toBe('IMG');
expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc); expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
}); });
it('should properly compute tooltipContainer', function () { it('should properly compute tooltipContainer', function() {
expect(vm.tooltipContainer).toBe('body'); expect(vm.tooltipContainer).toBe('body');
}); });
it('should properly render tooltipContainer', function () { it('should properly render tooltipContainer', function() {
expect(vm.$el.getAttribute('data-container')).toBe('body'); expect(vm.$el.getAttribute('data-container')).toBe('body');
}); });
it('should properly compute avatarSizeClass', function () { it('should properly compute avatarSizeClass', function() {
expect(vm.avatarSizeClass).toBe('s99'); expect(vm.avatarSizeClass).toBe('s99');
}); });
it('should properly render img css', function () { it('should properly render img css', function() {
const { classList } = vm.$el; const { classList } = vm.$el;
const containsAvatar = classList.contains('avatar'); const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99'); const containsSizeClass = classList.contains('s99');
...@@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () { ...@@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () {
}); });
}); });
describe('Initialization when lazy', function () { describe('Initialization when lazy', function() {
beforeEach(function () { beforeEach(function() {
vm = mountComponent(UserAvatarImage, { vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS, ...DEFAULT_PROPS,
lazy: true, lazy: true,
}).$mount(); }).$mount();
}); });
it('should add lazy attributes', function () { it('should add lazy attributes', function() {
const { classList } = vm.$el; const { classList } = vm.$el;
const lazyClass = classList.contains('lazy'); const lazyClass = classList.contains('lazy');
expect(lazyClass).toBe(true); expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage); expect(vm.$el.getAttribute('src')).toBe(placeholderImage);
expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc); expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
}); });
}); });
}); });
...@@ -95,7 +95,9 @@ describe('Card security reports app', () => { ...@@ -95,7 +95,9 @@ describe('Card security reports app', () => {
expect(userAvatarLink).not.toBeNull(); expect(userAvatarLink).not.toBeNull();
expect(userAvatarLink.getAttribute('href')).toBe(`${TEST_HOST}/user`); expect(userAvatarLink.getAttribute('href')).toBe(`${TEST_HOST}/user`);
expect(userAvatarLink.querySelector('img').getAttribute('src')).toBe(`${TEST_HOST}/img`); expect(userAvatarLink.querySelector('img').getAttribute('src')).toBe(
`${TEST_HOST}/img?width=24`,
);
expect(userAvatarLink.textContent).toBe('TestUser'); expect(userAvatarLink.textContent).toBe('TestUser');
}); });
......
...@@ -43,6 +43,10 @@ describe Avatarable do ...@@ -43,6 +43,10 @@ describe Avatarable do
expect(project.avatar_path(only_path: only_path)).to eq(avatar_path) expect(project.avatar_path(only_path: only_path)).to eq(avatar_path)
end end
it 'returns the expected avatar path with width parameter' do
expect(project.avatar_path(only_path: only_path, size: 128)).to eq(avatar_path + "?width=128")
end
context "when avatar is stored remotely" do context "when avatar is stored remotely" do
before do before do
stub_uploads_object_storage(AvatarUploader) stub_uploads_object_storage(AvatarUploader)
......
...@@ -1570,6 +1570,16 @@ describe MergeRequest do ...@@ -1570,6 +1570,16 @@ describe MergeRequest do
project.default_branch == branch) project.default_branch == branch)
end end
context 'but merged at timestamp cannot be found' do
before do
allow(subject).to receive(:merged_at) { nil }
end
it 'returns false' do
expect(subject.can_be_reverted?(current_user)).to be_falsey
end
end
context 'when the revert commit is mentioned in a note after the MR was merged' do context 'when the revert commit is mentioned in a note after the MR was merged' do
it 'returns false' do it 'returns false' do
expect(subject.can_be_reverted?(current_user)).to be_falsey expect(subject.can_be_reverted?(current_user)).to be_falsey
...@@ -1609,6 +1619,63 @@ describe MergeRequest do ...@@ -1609,6 +1619,63 @@ describe MergeRequest do
end end
end end
describe '#merged_at' do
context 'when MR is not merged' do
let(:merge_request) { create(:merge_request, :closed) }
it 'returns nil' do
expect(merge_request.merged_at).to be_nil
end
end
context 'when metrics has merged_at data' do
let(:merge_request) { create(:merge_request, :merged) }
before do
merge_request.metrics.update!(merged_at: 1.day.ago)
end
it 'returns metrics merged_at' do
expect(merge_request.merged_at).to eq(merge_request.metrics.merged_at)
end
end
context 'when merged event is persisted, but no metrics merged_at is persisted' do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, :merged) }
before do
EventCreateService.new.merge_mr(merge_request, user)
end
it 'returns merged event creation date' do
expect(merge_request.merge_event).to be_persisted
expect(merge_request.merged_at).to eq(merge_request.merge_event.created_at)
end
end
context 'when merging note is persisted, but no metrics or merge event exists' do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, :merged) }
before do
merge_request.metrics.destroy!
SystemNoteService.change_status(merge_request,
merge_request.target_project,
user,
merge_request.state, nil)
end
it 'returns merging note creation date' do
expect(merge_request.reload.metrics).to be_nil
expect(merge_request.merge_event).to be_nil
expect(merge_request.notes.count).to eq(1)
expect(merge_request.merged_at).to eq(merge_request.notes.first.created_at)
end
end
end
describe '#participants' do describe '#participants' do
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment