Commit 76bca24e authored by Phil Hughes's avatar Phil Hughes

Added previewing of batch comments

Adds a dropdown to the batch comments review bar
which allows users to preview the comments before submitting the review.
Clicking on comments in this dropdowns scrolls the user to the comment,
if the comment is on the discussion tab,
it changes tab before scrolling.

Closes https://gitlab.com/gitlab-org/gitlab-ee/issues/4327
parent 23960c59
......@@ -192,5 +192,8 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
return getters.unresolvedDiscussionsIdsByDate[0];
};
export const getDiscussion = state => discussionId =>
state.discussions.find(discussion => discussion.id === discussionId);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import NoteableNote from '~/notes/components/noteable_note.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import resolvedStatusMixin from '../mixins/resolved_status';
import PublishButton from './publish_button.vue';
export default {
......@@ -13,6 +13,7 @@ export default {
Icon,
LoadingButton,
},
mixins: [resolvedStatusMixin],
props: {
draft: {
type: Object,
......@@ -26,38 +27,23 @@ export default {
},
computed: {
...mapState('batchComments', ['isPublishing']),
...mapGetters(['isDiscussionResolved']),
...mapGetters('batchComments', ['isPublishingDraft']),
resolvedStatusMessage() {
let message;
const discussionResolved = this.isDiscussionResolved(this.draft.discussion_id);
const discussionToBeResolved = this.draft.resolve_discussion;
if (discussionToBeResolved) {
if (discussionResolved) {
message = s__('MergeRequests|Discussion stays resolved.');
} else {
message = s__('MergeRequests|Discussion will be resolved.');
}
} else if (discussionResolved) {
message = s__('MergeRequests|Discussion will be unresolved.');
} else {
message = s__('MergeRequests|Discussion stays unresolved.');
}
return message;
},
componentClasses() {
return this.draft.resolve_discussion
? 'is-resolving-discussion'
: 'is-unresolving-discussion';
},
draftCommands() {
return this.draft.references.commands;
},
},
mounted() {
if (window.location.hash && window.location.hash === `#note_${this.draft.id}`) {
this.scrollToDraft(this.draft);
}
},
methods: {
...mapActions('batchComments', ['deleteDraft', 'updateDraft', 'publishSingleDraft']),
...mapActions('batchComments', [
'deleteDraft',
'updateDraft',
'publishSingleDraft',
'scrollToDraft',
]),
update(data) {
this.updateDraft(data);
},
......@@ -71,6 +57,7 @@ export default {
this.isEditingDraft = false;
},
},
showStaysResolved: true,
};
</script>
<template>
......@@ -117,6 +104,8 @@ export default {
<p class="draft-note-actions">
<publish-button
:show-count="true"
:should-publish="false"
class="btn btn-success btn-inverted"
/>
<loading-button
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
import { sprintf, n__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import DraftsCount from './drafts_count.vue';
import PublishButton from './publish_button.vue';
import PreviewItem from './preview_item.vue';
export default {
components: {
GlLoadingIcon,
Icon,
DraftsCount,
PublishButton,
PreviewItem,
},
computed: {
...mapGetters(['isNotesFetched']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
...mapState('batchComments', ['showPreviewDropdown']),
dropdownTitle() {
return sprintf(
n__('%{count} pending comment', '%{count} pending comments', this.draftsCount),
{ count: this.draftsCount },
);
},
},
watch: {
showPreviewDropdown() {
if (this.showPreviewDropdown && this.$refs.dropdown) {
this.$nextTick(() => this.$refs.dropdown.focus());
}
},
},
mounted() {
document.addEventListener('click', this.onClickDocument);
},
beforeDestroy() {
document.removeEventListener('click', this.onClickDocument);
},
methods: {
...mapActions('batchComments', ['toggleReviewDropdown']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
onClickDocument({ target }) {
if (
this.showPreviewDropdown &&
!target.closest('.review-preview-dropdown, .js-publish-draft-button')
) {
this.toggleReviewDropdown();
}
},
},
};
</script>
<template>
<div
class="dropdown float-right review-preview-dropdown"
:class="{
show: showPreviewDropdown
}"
>
<button
ref="dropdown"
type="button"
class="btn btn-success review-preview-dropdown-toggle"
@click="toggleReviewDropdown"
>
{{ __('Finish review') }}
<drafts-count />
<icon
name="angle-up"
/>
</button>
<div
class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top"
:class="{
show: showPreviewDropdown
}"
>
<div class="dropdown-title">
{{ dropdownTitle }}
<button
:aria-label="__('Close')"
type="button"
class="dropdown-title-button dropdown-menu-close"
@click="toggleReviewDropdown"
>
<icon
name="close"
/>
</button>
</div>
<div class="dropdown-content">
<ul v-if="isNotesFetched">
<li
v-for="(draft, index) in sortedDrafts"
:key="draft.id"
>
<preview-item
:draft="draft"
:is-last="isLast(index)"
/>
</li>
</ul>
<gl-loading-icon
v-else
:size="2"
class="prepend-top-default append-bottom-default"
/>
</div>
<div class="dropdown-footer">
<publish-button
:show-count="false"
:should-publish="true"
:label="__('Submit review')"
class="float-right append-right-8"
/>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import resolvedStatusMixin from '../mixins/resolved_status';
export default {
components: {
Icon,
},
mixins: [resolvedStatusMixin],
props: {
draft: {
type: Object,
required: true,
},
isLast: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters('diffs', ['getDiffFileByHash']),
...mapGetters(['getDiscussion']),
iconName() {
return this.isDiffDiscussion || this.draft.line_code ? 'doc-text' : 'comment';
},
discussion() {
return this.getDiscussion(this.draft.discussion_id);
},
isDiffDiscussion() {
return this.discussion && this.discussion.diff_discussion;
},
titleText() {
const file = this.discussion ? this.discussion.diff_file : this.draft;
if (file) {
return file.file_path;
}
return sprintf(__("%{authorsName}'s discussion"), {
authorsName: this.discussion.notes.find(note => !note.system).author.name,
});
},
linePosition() {
const position = this.discussion ? this.discussion.position : this.draft.position;
return position.new_line || position.old_line;
},
content() {
const el = document.createElement('div');
el.innerHTML = this.draft.note_html;
return el.textContent;
},
showLinePosition() {
return this.draft.file_hash || this.isDiffDiscussion;
},
},
methods: {
...mapActions('batchComments', ['scrollToDraft']),
},
showStaysResolved: false,
};
</script>
<template>
<button
type="button"
class="review-preview-item menu-item"
:class="[componentClasses, {
'is-last': isLast
}]"
@click="scrollToDraft(draft)"
>
<span class="review-preview-item-header">
<icon
class="append-right-8 flex-shrink-0"
:name="iconName"
/>
<span class="bold">
<span class="review-preview-item-header-text block-truncated">
{{ titleText }}
</span>
<template v-if="showLinePosition">
:{{ linePosition }}
</template>
</span>
</span>
<span class="review-preview-item-content">
<p>{{ content }}</p>
</span>
<span
v-if="draft.discussion_id && resolvedStatusMessage"
class="review-preview-item-footer draft-note-resolution p-0"
>
<icon
class="append-right-8"
name="status_success"
/>
{{ resolvedStatusMessage }}
</span>
</button>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import DraftsCount from './drafts_count.vue';
......@@ -8,11 +9,34 @@ export default {
LoadingButton,
DraftsCount,
},
props: {
showCount: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: __('Finish review'),
},
shouldPublish: {
type: Boolean,
required: true,
},
},
computed: {
...mapState('batchComments', ['isPublishing']),
},
methods: {
...mapActions('batchComments', ['publishReview']),
...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']),
onClick() {
if (this.shouldPublish) {
this.publishReview();
} else {
this.toggleReviewDropdown();
}
},
},
};
</script>
......@@ -20,12 +44,14 @@ export default {
<template>
<loading-button
:loading="isPublishing"
container-class="btn btn-success"
@click="publishReview"
container-class="btn btn-success js-publish-draft-button"
@click="onClick"
>
<span>
{{ __('Submit review') }}
<drafts-count />
{{ label }}
<drafts-count
v-if="showCount"
/>
</span>
</loading-button>
</template>
......@@ -3,23 +3,31 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { GlModal, GlModalDirective } from '@gitlab-org/gitlab-ui';
import PublishButton from './publish_button.vue';
import PreviewDropdown from './preview_dropdown.vue';
export default {
components: {
PublishButton,
LoadingButton,
GlModal,
PreviewDropdown,
},
directives: {
'gl-modal': GlModalDirective,
},
computed: {
...mapGetters(['isNotesFetched']),
...mapState('batchComments', ['isDiscarding']),
...mapGetters('batchComments', ['draftsCount']),
},
watch: {
isNotesFetched() {
if (this.isNotesFetched) {
this.expandAllDiscussions();
}
},
},
methods: {
...mapActions('batchComments', ['discardReview']),
...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']),
},
modalId: 'discard-draft-review',
text: sprintf(
......@@ -38,14 +46,15 @@ export default {
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
<p class="review-bar-content">
<publish-button />
<div class="review-bar-content">
<preview-dropdown />
<loading-button
v-gl-modal="$options.modalId"
:loading="isDiscarding"
:label="__('Discard review')"
class="float-right"
/>
</p>
</div>
</nav>
<gl-modal
:title="s__('BatchComments|Discard review?')"
......
export const CHANGES_TAB = 'diffs';
export const DISCUSSION_TAB = 'notes';
export const SHOW_TAB = 'show';
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
export default {
computed: {
...mapGetters(['isDiscussionResolved']),
resolvedStatusMessage() {
let message;
const discussionResolved = this.isDiscussionResolved(this.draft.discussion_id);
const discussionToBeResolved = this.draft.resolve_discussion;
if (discussionToBeResolved && discussionResolved && !this.$options.showStaysResolved) {
return undefined;
}
if (discussionToBeResolved) {
if (discussionResolved) {
message = s__('MergeRequests|Discussion stays resolved.');
} else {
message = s__('MergeRequests|Discussion will be resolved.');
}
} else if (discussionResolved) {
message = s__('MergeRequests|Discussion will be unresolved.');
} else if (this.$options.showStaysResolved) {
message = s__('MergeRequests|Discussion stays unresolved.');
}
return message;
},
componentClasses() {
return this.draft.resolve_discussion
? 'is-resolving-discussion'
: 'is-unresolving-discussion';
},
},
};
import flash from '~/flash';
import { __ } from '~/locale';
import { scrollToElement } from '~/lib/utils/common_utils';
import service from '../../../services/drafts_service';
import * as types from './mutation_types';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
export const enableBatchComments = ({ commit }) => {
commit(types.ENABLE_BATCH_COMMENTS);
......@@ -94,5 +96,46 @@ export const updateDraft = ({ commit, getters }, { note, noteText, callback }) =
.then(callback)
.catch(() => flash(__('An error occurred while updating the comment')));
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id);
const tab =
draft.file_hash || (discussion && discussion.diff_discussion) ? CHANGES_TAB : SHOW_TAB;
const tabEl = tab === CHANGES_TAB ? CHANGES_TAB : DISCUSSION_TAB;
const draftID = `note_${draft.id}`;
const el = document.querySelector(`#${tabEl} #${draftID}`);
dispatch('closeReviewDropdown');
window.location.hash = draftID;
if (window.mrTabs.currentAction !== tab) {
window.mrTabs.tabShown(tab);
}
if (discussion) {
dispatch('expandDiscussion', { discussionId: discussion.id }, { root: true });
}
if (el) {
setTimeout(() => scrollToElement(el.closest('.draft-note-component')));
}
};
export const toggleReviewDropdown = ({ dispatch, state }) => {
if (state.showPreviewDropdown) {
dispatch('closeReviewDropdown');
} else {
dispatch('openReviewDropdown');
}
};
export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN);
export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN);
export const expandAllDiscussions = ({ dispatch, state }) =>
state.drafts.filter(draft => draft.discussion_id).forEach(draft => {
dispatch('expandDiscussion', { discussionId: draft.discussion_id }, { root: true });
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -64,5 +64,7 @@ export const draftForLine = (state, getters) => (diffFileSha, line, side = null)
export const isPublishingDraft = state => draftId =>
state.currentlyPublishingDrafts.indexOf(draftId) !== -1;
export const sortedDrafts = state => [...state.drafts].sort((a, b) => a.id > b.id);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -16,3 +16,6 @@ export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN';
export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN';
......@@ -64,4 +64,10 @@ export default {
state.drafts.splice(index, 1, processDraft(data));
}
},
[types.OPEN_REVIEW_DROPDOWN](state) {
state.showPreviewDropdown = true;
},
[types.CLOSE_REVIEW_DROPDOWN](state) {
state.showPreviewDropdown = false;
},
};
......@@ -5,4 +5,5 @@ export default () => ({
isPublishing: false,
currentlyPublishingDrafts: [],
isDiscarding: false,
showPreviewDropdown: false,
});
......@@ -23,18 +23,6 @@
}
}
&.is-resolving-discussion {
.draft-note-resolution svg {
color: $green-600;
}
}
&.is-unresolving-discussion {
.draft-note-resolution svg {
color: $gray-darkest;
}
}
.referenced-commands.draft-note-commands {
background: $orange-100;
font-size: $label-font-size;
......@@ -63,17 +51,29 @@ button[disabled] {
display: flex;
justify-content: space-between;
align-items: center;
}
.draft-note-resolution {
padding: $gl-padding-4 $gl-padding;
line-height: 1;
font-size: $label-font-size;
color: $theme-gray-700;
flex-grow: 1;
.draft-note-resolution {
padding: $gl-padding-4 $gl-padding;
line-height: 1;
font-size: $label-font-size;
color: $theme-gray-700;
flex-grow: 1;
svg {
vertical-align: text-bottom;
display: inline-block;
}
.is-resolving-discussion & {
svg {
vertical-align: text-bottom;
display: inline-block;
color: $green-600;
}
}
.is-unresolving-discussion & {
svg {
color: $gray-darkest;
}
}
}
......
......@@ -8,6 +8,6 @@ $drafts-count-size: 1.6;
line-height: $drafts-count-size;
background: $black-transparent;
border-radius: 50%;
margin-left: 0.5em;
margin-left: $gl-padding-4;
padding: 0 $gl-padding-4;
}
......@@ -4,7 +4,7 @@
left: 0;
width: 100%;
background: $white-light;
z-index: 100;
z-index: 300;
padding: 7px 0 6px; // to keep aligned with "collapse sidebar" button on the left sidebar
border-top: 1px solid $border-color;
padding-left: $contextual-sidebar-width;
......@@ -24,18 +24,8 @@
padding-right: 0;
}
p {
@include clearfix;
margin: 0 auto;
text-align: right;
}
.btn {
float: right;
+ .btn {
margin-right: $grid-size;
}
.dropdown {
margin-left: $grid-size;
}
}
......@@ -45,3 +35,88 @@
width: 100%;
margin: 0 auto;
}
.review-preview-dropdown {
.review-preview-item.menu-item {
display: flex;
flex-wrap: wrap;
padding: 8px 16px;
cursor: pointer;
&:not(.is-last) {
border-bottom: 1px solid $list-border;
}
}
.dropdown-menu {
top: auto;
bottom: 36px;
&.show {
max-height: 400px;
@include media-breakpoint-down(xs) {
width: calc(100vw - 32px);
}
}
}
.dropdown-content {
max-height: 300px;
}
.dropdown-title {
padding: $grid-size 25px $gl-padding;
margin-bottom: 0;
}
.dropdown-footer {
margin-top: 0;
}
.dropdown-menu-close {
top: 6px;
}
}
.review-preview-dropdown-toggle {
svg.s16 {
width: 15px;
height: 15px;
margin-top: -1px;
top: 3px;
margin-left: 4px;
}
}
.review-preview-item-header {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 4px;
> .bold {
display: flex;
min-width: 0;
line-height: 16px;
}
}
.review-preview-item-footer {
display: flex;
align-items: center;
margin-top: 4px;
}
.review-preview-item-content {
width: 100%;
p {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0;
}
}
......@@ -80,6 +80,12 @@ class DraftNote < ActiveRecord::Base
Digest::SHA1.hexdigest(diff_file.file_path)
end
def file_path
return unless diff_file
diff_file.file_path
end
def publish_params
attrs = PUBLISH_ATTRS.dup
attrs.concat(DIFF_ATTRS) if on_diff?
......
......@@ -8,6 +8,7 @@ class DraftNoteEntity < Grape::Entity
expose :position, if: -> (note, _) { note.on_diff? }
expose :line_code
expose :file_hash
expose :file_path
expose :note
expose :rendered_note, as: :note_html
expose :references
......
---
title: Enable previewing of draft review comments
merge_request: 7936
author:
type: added
......@@ -53,6 +53,7 @@ describe 'Merge request > Batch comments', :js do
write_comment
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end
......@@ -155,6 +156,7 @@ describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(resolve: true)
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end
......@@ -197,6 +199,7 @@ describe 'Merge request > Batch comments', :js do
write_reply_to_discussion(button_text: 'Start a review', unresolve: true)
page.within('.review-bar-content') do
click_button 'Finish review'
click_button 'Submit review'
end
......
import Vue from 'vue';
import PreviewItem from 'ee/batch_comments/components/preview_item.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
describe('Batch comments draft preview item component', () => {
let vm;
let Component;
let draft;
function createComponent(isLast = false, extra = {}, extendStore = () => {}) {
const store = createStore();
extendStore(store);
draft = {
...createDraft(),
...extra,
};
vm = mountComponentWithStore(Component, { store, props: { draft, isLast } });
}
beforeAll(() => {
Component = Vue.extend(PreviewItem);
});
afterEach(() => {
vm.$destroy();
});
it('renders text content', () => {
createComponent(false, { note_html: '<img src="" /><p>Hello world</p>' });
expect(vm.$el.querySelector('.review-preview-item-content').innerHTML).toEqual(
'<p>Hello world</p>',
);
});
it('adds is last class', () => {
createComponent(true);
expect(vm.$el.classList).toContain('is-last');
});
it('scrolls to draft on click', () => {
createComponent();
spyOn(vm.$store, 'dispatch').and.stub();
vm.$el.click();
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/scrollToDraft', vm.draft);
});
describe('for file', () => {
it('renders file path', () => {
createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} });
expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain(
'index.js',
);
});
it('renders new line position', () => {
createComponent(false, {
file_path: 'index.js',
file_hash: 'abc',
position: { new_line: 1 },
});
expect(vm.$el.querySelector('.bold').textContent).toContain(':1');
});
it('renders old line position', () => {
createComponent(false, {
file_path: 'index.js',
file_hash: 'abc',
position: { old_line: 2 },
});
expect(vm.$el.querySelector('.bold').textContent).toContain(':2');
});
});
describe('for discussion', () => {
beforeEach(() => {
createComponent(false, { discussion_id: '1', resolve_discussion: true }, store => {
store.state.notes.discussions.push({
id: '1',
notes: [
{
author: {
name: 'Author Name',
},
},
],
});
});
});
it('renders title', () => {
expect(vm.$el.querySelector('.review-preview-item-header-text').textContent).toContain(
"Author Name's discussion",
);
});
it('it renders discussion resolved text', () => {
expect(vm.$el.querySelector('.draft-note-resolution').textContent).toContain(
'Discussion will be resolved',
);
});
});
});
......@@ -3,7 +3,7 @@ import PublishButton from 'ee/batch_comments/components/publish_button.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from 'ee/batch_comments/stores';
describe('Batch comments drafts count component', () => {
describe('Batch comments publish button component', () => {
let vm;
let Component;
......@@ -14,7 +14,7 @@ describe('Batch comments drafts count component', () => {
beforeEach(() => {
const store = createStore();
vm = mountComponentWithStore(Component, { store });
vm = mountComponentWithStore(Component, { store, props: { shouldPublish: true } });
spyOn(vm.$store, 'dispatch').and.stub();
});
......@@ -26,9 +26,17 @@ describe('Batch comments drafts count component', () => {
it('dispatches publishReview on click', () => {
vm.$el.click();
expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined);
});
it('dispatches toggleReviewDropdown when shouldPublish is false on click', () => {
vm.shouldPublish = false;
vm.$el.click();
expect(vm.$store.dispatch).toHaveBeenCalledWith(
'batchComments/publishReview',
jasmine.anything(),
'batchComments/toggleReviewDropdown',
undefined,
);
});
......
import Vue from 'vue';
import PreviewDropdown from 'ee/batch_comments/components/preview_dropdown.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
import '~/behaviors/markdown/render_gfm';
import { createDraft } from '../mock_data';
describe('Batch comments draft preview item component', () => {
let vm;
let Component;
function createComponent(extendStore = () => {}) {
const store = createStore();
store.state.batchComments.drafts.push(createDraft(), { ...createDraft(), id: 2 });
extendStore(store);
vm = mountComponentWithStore(Component, { store });
}
beforeAll(() => {
Component = Vue.extend(PreviewDropdown);
});
afterEach(() => {
vm.$destroy();
});
it('toggles dropdown when clicking button', done => {
createComponent();
spyOn(vm.$store, 'dispatch').and.callThrough();
vm.$el.querySelector('.review-preview-dropdown-toggle').click();
expect(vm.$store.dispatch).toHaveBeenCalledWith(
'batchComments/toggleReviewDropdown',
jasmine.anything(),
);
setTimeout(() => {
expect(vm.$el.classList).toContain('show');
done();
});
});
it('toggles dropdown when clicking body', () => {
createComponent();
vm.$store.state.batchComments.showPreviewDropdown = true;
spyOn(vm.$store, 'dispatch').and.stub();
document.body.click();
expect(vm.$store.dispatch).toHaveBeenCalledWith(
'batchComments/toggleReviewDropdown',
undefined,
);
});
it('renders list of drafts', () => {
createComponent(store => {
Object.assign(store.state.notes, {
isNotesFetched: true,
});
});
expect(vm.$el.querySelectorAll('.dropdown-content li').length).toBe(2);
});
it('adds is-last class to last item', () => {
createComponent(store => {
Object.assign(store.state.notes, {
isNotesFetched: true,
});
});
expect(vm.$el.querySelectorAll('.dropdown-content li')[1].querySelector('.is-last')).not.toBe(
null,
);
});
it('renders draft count in dropdown title', () => {
createComponent();
expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('2 pending comments');
});
it('renders publish button in footer', () => {
createComponent();
expect(vm.$el.querySelector('.dropdown-footer .js-publish-draft-button')).not.toBe(null);
});
});
......@@ -10,6 +10,7 @@ export const createDraft = () => ({
current_user: { can_edit: true, can_award_emoji: false, can_resolve: false },
discussion_id: null,
file_hash: null,
file_path: null,
id: 1,
line_code: null,
merge_request_id: 1,
......@@ -19,4 +20,5 @@ export const createDraft = () => ({
references: { users: [], commands: '' },
resolve_discussion: false,
isDraft: true,
position: null,
});
......@@ -309,4 +309,115 @@ describe('Batch comments store actions', () => {
.catch(done.fail);
});
});
describe('toggleReviewDropdown', () => {
it('dispatches openReviewDropdown', done => {
testAction(
actions.toggleReviewDropdown,
null,
{ showPreviewDropdown: false },
[],
[{ type: 'openReviewDropdown' }],
done,
);
});
it('dispatches closeReviewDropdown when showPreviewDropdown is true', done => {
testAction(
actions.toggleReviewDropdown,
null,
{ showPreviewDropdown: true },
[],
[{ type: 'closeReviewDropdown' }],
done,
);
});
});
describe('openReviewDropdown', () => {
it('commits OPEN_REVIEW_DROPDOWN', done => {
testAction(
actions.openReviewDropdown,
null,
null,
[{ type: 'OPEN_REVIEW_DROPDOWN' }],
[],
done,
);
});
});
describe('closeReviewDropdown', () => {
it('commits CLOSE_REVIEW_DROPDOWN', done => {
testAction(
actions.closeReviewDropdown,
null,
null,
[{ type: 'CLOSE_REVIEW_DROPDOWN' }],
[],
done,
);
});
});
describe('expandAllDiscussions', () => {
it('dispatches expandDiscussion for all drafts', done => {
const state = {
drafts: [
{
discussion_id: '1',
},
],
};
testAction(
actions.expandAllDiscussions,
null,
state,
[],
[
{
type: 'expandDiscussion',
payload: { discussionId: '1' },
},
],
done,
);
});
});
describe('scrollToDraft', () => {
beforeEach(() => {
window.mrTabs = {
currentAction: 'notes',
tabShown: jasmine.createSpy('tabShown'),
};
});
it('scrolls to draft item', () => {
const dispatch = jasmine.createSpy('dispatch');
const rootGetters = {
getDiscussion: () => ({
id: '1',
diff_discussion: true,
}),
};
const draft = {
discussion_id: '1',
id: '2',
};
actions.scrollToDraft({ dispatch, rootGetters }, draft);
expect(dispatch.calls.argsFor(0)).toEqual(['closeReviewDropdown']);
expect(dispatch.calls.argsFor(1)).toEqual([
'expandDiscussion',
{ discussionId: '1' },
{ root: true },
]);
expect(window.mrTabs.tabShown).toHaveBeenCalledWith('diffs');
});
});
});
......@@ -148,4 +148,20 @@ describe('Batch comments mutations', () => {
]);
});
});
describe(types.OPEN_REVIEW_DROPDOWN, () => {
it('sets showPreviewDropdown to true', () => {
mutations[types.OPEN_REVIEW_DROPDOWN](state);
expect(state.showPreviewDropdown).toBe(true);
});
});
describe(types.CLOSE_REVIEW_DROPDOWN, () => {
it('sets showPreviewDropdown to false', () => {
mutations[types.CLOSE_REVIEW_DROPDOWN](state);
expect(state.showPreviewDropdown).toBe(false);
});
});
});
......@@ -113,6 +113,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr ""
msgid "%{authorsName}'s discussion"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
......@@ -127,6 +130,11 @@ msgid_plural "%{count} participants"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} pending comment"
msgid_plural "%{count} pending comments"
msgstr[0] ""
msgstr[1] ""
msgid "%{filePath} deleted"
msgstr ""
......@@ -3410,6 +3418,9 @@ msgstr ""
msgid "Fingerprints"
msgstr ""
msgid "Finish review"
msgstr ""
msgid "Finished"
msgstr ""
......
......@@ -265,4 +265,12 @@ describe('Getters Notes Store', () => {
expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy();
});
});
describe('getDiscussion', () => {
it('returns discussion by ID', () => {
state.discussions.push({ id: '1' });
expect(getters.getDiscussion(state)('1')).toEqual({ id: '1' });
});
});
});
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