Commit 95310793 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '225291-show-alert-for-issue-email-participants' into 'master'

Expose issue_email_participant and create component

See merge request gitlab-org/gitlab!48261
parents 943aca13 f432e9db
......@@ -16,12 +16,12 @@ import { sprintf, s__ } from '~/locale';
*
* @param {String[]} items
*/
export const toNounSeriesText = (items) => {
export const toNounSeriesText = (items, { onlyCommas = false } = {}) => {
if (items.length === 0) {
return '';
} else if (items.length === 1) {
return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false);
} else if (items.length === 2) {
} else if (items.length === 2 && !onlyCommas) {
return sprintf(
s__('nounSeries|%{firstItem} and %{lastItem}'),
{
......@@ -33,7 +33,7 @@ export const toNounSeriesText = (items) => {
}
return items.reduce((item, nextItem, idx) =>
idx === items.length - 1
idx === items.length - 1 && !onlyCommas
? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false)
: sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false),
);
......
<script>
import EmailParticipantsWarning from './email_participants_warning.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
const DEFAULT_NOTEABLE_TYPE = 'Issue';
export default {
components: {
EmailParticipantsWarning,
NoteableWarning,
},
props: {
noteableData: {
type: Object,
required: true,
},
noteableType: {
type: String,
required: false,
default: DEFAULT_NOTEABLE_TYPE,
},
withAlertContainer: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isLocked() {
return Boolean(this.noteableData.discussion_locked);
},
isConfidential() {
return Boolean(this.noteableData.confidential);
},
hasWarning() {
return this.isConfidential || this.isLocked;
},
emailParticipants() {
return this.noteableData.issue_email_participants?.map(({ email }) => email) || [];
},
},
};
</script>
<template>
<div
class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
>
<div
v-if="withAlertContainer"
class="error-alert"
data-testid="comment-field-alert-container"
></div>
<noteable-warning
v-if="hasWarning"
class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
:is-locked="isLocked"
:is-confidential="isConfidential"
:noteable-type="noteableType"
:locked-noteable-docs-path="noteableData.locked_discussion_docs_path"
:confidential-noteable-docs-path="noteableData.confidential_issues_docs_path"
/>
<slot></slot>
<email-participants-warning
v-if="emailParticipants.length"
class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!"
:emails="emailParticipants"
/>
</div>
</template>
......@@ -17,18 +17,17 @@ import {
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
export default {
name: 'CommentForm',
components: {
NoteableWarning,
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
......@@ -36,6 +35,7 @@ export default {
GlButton,
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: {
......@@ -287,6 +287,9 @@ export default {
Autosize.update(this.$refs.textarea);
});
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
},
};
</script>
......@@ -309,46 +312,41 @@ export default {
</div>
<div class="timeline-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<div class="error-alert"></div>
<noteable-warning
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
<comment-field-layout
:with-alert-container="true"
:noteable-data="getNoteableData"
:noteable-type="noteableType"
:locked-noteable-docs-path="lockedIssueDocsPath"
:confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:textarea-value="note"
>
<textarea
id="note-body"
ref="textarea"
slot="textarea"
v-model="note"
dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
></textarea>
</markdown-field>
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:textarea-value="note"
>
<template #textarea>
<textarea
id="note-body"
ref="textarea"
v-model="note"
dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
></textarea>
</template>
</markdown-field>
</comment-field-layout>
<div class="note-form-actions">
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
......
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { toNounSeriesText } from '~/lib/utils/grammar';
export default {
components: {
GlSprintf,
},
props: {
emails: {
type: Array,
required: true,
},
numberOfLessParticipants: {
type: Number,
required: false,
default: 3,
},
},
data() {
return {
isShowingMoreParticipants: false,
};
},
computed: {
title() {
return this.moreParticipantsAvailable
? toNounSeriesText(this.lessParticipants, { onlyCommas: true })
: toNounSeriesText(this.emails);
},
lessParticipants() {
return this.emails.slice(0, this.numberOfLessParticipants);
},
moreLabel() {
return sprintf(s__('EmailParticipantsWarning|and %{moreCount} more'), {
moreCount: this.emails.length - this.numberOfLessParticipants,
});
},
moreParticipantsAvailable() {
return !this.isShowingMoreParticipants && this.emails.length > this.numberOfLessParticipants;
},
message() {
return this.moreParticipantsAvailable
? s__('EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment.')
: s__('EmailParticipantsWarning|%{emails} will be notified of your comment.');
},
},
methods: {
showMoreParticipants() {
this.isShowingMoreParticipants = true;
},
},
};
</script>
<template>
<div class="issuable-note-warning" data-testid="email-participants-warning">
<gl-sprintf :message="message">
<template #andMore>
<button type="button" class="btn-transparent btn-link" @click="showMoreParticipants">
{{ moreLabel }}
</button>
</template>
<template #emails>
<span>{{ title }}</span>
</template>
</gl-sprintf>
</div>
</template>
......@@ -3,19 +3,19 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
import { __, sprintf } from '~/locale';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
import CommentFieldLayout from './comment_field_layout.vue';
export default {
name: 'NoteForm',
components: {
NoteableWarning,
markdownField,
CommentFieldLayout,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
props: {
......@@ -303,6 +303,9 @@ export default {
this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
},
};
</script>
......@@ -316,46 +319,41 @@ export default {
></div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<noteable-warning
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
:locked-noteable-docs-path="lockedIssueDocsPath"
:confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<textarea
id="note_note"
ref="textarea"
slot="textarea"
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
@keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</markdown-field>
<comment-field-layout :noteable-data="getNoteableData">
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<textarea
id="note_note"
ref="textarea"
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
@keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</template>
</markdown-field>
</comment-field-layout>
<div class="note-form-actions clearfix">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
......
......@@ -12,21 +12,10 @@ export default {
lockedIssueDocsPath() {
return this.getNoteableDataByProp('locked_discussion_docs_path');
},
confidentialIssueDocsPath() {
return this.getNoteableDataByProp('confidential_issues_docs_path');
},
},
methods: {
isConfidential(issue) {
return Boolean(issue.confidential);
},
isLocked(issue) {
return Boolean(issue.discussion_locked);
},
hasWarning(issue) {
return this.isConfidential(issue) || this.isLocked(issue);
},
},
};
......@@ -100,8 +100,6 @@
color: $orange-600;
background-color: $orange-50;
border-radius: $border-radius-default $border-radius-default 0 0;
border: 1px solid $border-gray-normal;
border-bottom: 0;
padding: 3px 12px;
margin: auto;
align-items: center;
......@@ -454,3 +452,9 @@ table {
.markdown-selector {
color: $blue-600;
}
.comment-warning-wrapper {
.md-area {
border: 0;
}
}
......@@ -71,6 +71,10 @@ class IssueEntity < IssuableEntity
expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue|
help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
end
expose :issue_email_participants do |issue|
issue.issue_email_participants.map { |x| { email: x.email } }
end
end
IssueEntity.prepend_if_ee('::EE::IssueEntity')
......@@ -10617,6 +10617,15 @@ msgstr ""
msgid "EmailError|Your account has been blocked. If you believe this is in error, contact a staff member."
msgstr ""
msgid "EmailParticipantsWarning|%{emails} will be notified of your comment."
msgstr ""
msgid "EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment."
msgstr ""
msgid "EmailParticipantsWarning|and %{moreCount} more"
msgstr ""
msgid "EmailToken|reset it"
msgstr ""
......
......@@ -227,6 +227,22 @@ RSpec.describe Projects::IssuesController do
end
end
describe "GET #show" do
before do
sign_in(user)
project.add_developer(user)
end
it "returns issue_email_participants" do
participants = create_list(:issue_email_participant, 2, issue: issue)
get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }, format: :json
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issue_email_participants']).to contain_exactly({ "email" => participants[0].email }, { "email" => participants[1].email })
end
end
describe 'GET #new' do
it 'redirects to signin if not logged in' do
get :new, params: { namespace_id: project.namespace, project_id: project }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'viewing an issue', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:note) { create(:note_on_issue, project: project, noteable: issue) }
let_it_be(:participants) { create_list(:issue_email_participant, 4, issue: issue) }
before do
sign_in(user)
visit project_issue_path(project, issue)
end
shared_examples 'email participants warning' do |selector|
it 'shows the correct message' do
expect(find(selector)).to have_content(", and 1 more will be notified of your comment")
end
end
context 'for a new note' do
it_behaves_like 'email participants warning', '.new-note'
end
context 'for a reply form' do
before do
find('.js-reply-button').click
end
it_behaves_like 'email participants warning', '.note-edit-form'
end
end
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CommentFieldLayout from '~/notes/components/comment_field_layout.vue';
import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
describe('Comment Field Layout Component', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const LOCKED_DISCUSSION_DOCS_PATH = 'docs/locked/path';
const CONFIDENTIAL_ISSUES_DOCS_PATH = 'docs/confidential/path';
const noteableDataMock = {
confidential: false,
discussion_locked: false,
locked_discussion_docs_path: LOCKED_DISCUSSION_DOCS_PATH,
confidential_issues_docs_path: CONFIDENTIAL_ISSUES_DOCS_PATH,
};
const findIssuableNoteWarning = () => wrapper.find(NoteableWarning);
const findEmailParticipantsWarning = () => wrapper.find(EmailParticipantsWarning);
const findErrorAlert = () => wrapper.findByTestId('comment-field-alert-container');
const createWrapper = (props = {}, slots = {}) => {
wrapper = extendedWrapper(
shallowMount(CommentFieldLayout, {
propsData: {
noteableData: noteableDataMock,
...props,
},
slots,
}),
);
};
describe('.error-alert', () => {
it('does not exist by default', () => {
createWrapper();
expect(findErrorAlert().exists()).toBe(false);
});
it('exists when withAlertContainer is true', () => {
createWrapper({ withAlertContainer: true });
expect(findErrorAlert().isVisible()).toBe(true);
});
});
describe('issue is not confidential and not locked', () => {
it('does not show IssuableNoteWarning', () => {
createWrapper();
expect(findIssuableNoteWarning().exists()).toBe(false);
});
});
describe('issue is confidential', () => {
beforeEach(() => {
createWrapper({
noteableData: { ...noteableDataMock, confidential: true },
});
});
it('shows IssuableNoteWarning', () => {
expect(findIssuableNoteWarning().isVisible()).toBe(true);
});
it('sets IssuableNoteWarning props', () => {
expect(findIssuableNoteWarning().props()).toMatchObject({
isLocked: false,
isConfidential: true,
lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH,
confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH,
});
});
});
describe('issue is locked', () => {
beforeEach(() => {
createWrapper({
noteableData: { ...noteableDataMock, discussion_locked: true },
});
});
it('shows IssuableNoteWarning', () => {
expect(findIssuableNoteWarning().isVisible()).toBe(true);
});
it('sets IssuableNoteWarning props', () => {
expect(findIssuableNoteWarning().props()).toMatchObject({
isConfidential: false,
isLocked: true,
lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH,
confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH,
});
});
});
describe('issue has no email participants', () => {
it('does not show EmailParticipantsWarning', () => {
createWrapper();
expect(findEmailParticipantsWarning().exists()).toBe(false);
});
});
describe('issue has email participants', () => {
beforeEach(() => {
createWrapper({
noteableData: {
...noteableDataMock,
issue_email_participants: [
{ email: 'someone@gitlab.com' },
{ email: 'another@gitlab.com' },
],
},
});
});
it('shows EmailParticipantsWarning', () => {
expect(findEmailParticipantsWarning().isVisible()).toBe(true);
});
it('sets EmailParticipantsWarning props', () => {
expect(findEmailParticipantsWarning().props('emails')).toEqual([
'someone@gitlab.com',
'another@gitlab.com',
]);
});
});
});
......@@ -181,7 +181,7 @@ describe('issue_comment_form component', () => {
describe('edit mode', () => {
beforeEach(() => {
mountComponent();
mountComponent({ mountFunction: mount });
});
it('should enter edit mode when arrow up is pressed', () => {
......@@ -200,7 +200,7 @@ describe('issue_comment_form component', () => {
describe('event enter', () => {
beforeEach(() => {
mountComponent();
mountComponent({ mountFunction: mount });
});
it('should save note when cmd+enter is pressed', () => {
......@@ -368,17 +368,6 @@ describe('issue_comment_form component', () => {
});
});
});
describe('issue is confidential', () => {
it('shows information warning', () => {
mountComponent({
noteableData: { ...noteableDataMock, confidential: true },
mountFunction: mount,
});
expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true);
});
});
});
describe('user is not logged in', () => {
......
import { mount } from '@vue/test-utils';
import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue';
describe('Email Participants Warning Component', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findMoreButton = () => wrapper.find('button');
const createWrapper = (emails) => {
wrapper = mount(EmailParticipantsWarning, {
propsData: { emails },
});
};
describe('with 3 or less emails', () => {
beforeEach(() => {
createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com']);
});
it('more button does not exist', () => {
expect(findMoreButton().exists()).toBe(false);
});
it('all emails are displayed', () => {
expect(wrapper.text()).toBe(
'a@gitlab.com, b@gitlab.com, and c@gitlab.com will be notified of your comment.',
);
});
});
describe('with more than 3 emails', () => {
beforeEach(() => {
createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com', 'd@gitlab.com']);
});
it('only displays first 3 emails', () => {
expect(wrapper.text()).toContain('a@gitlab.com, b@gitlab.com, c@gitlab.com');
expect(wrapper.text()).not.toContain('d@gitlab.com');
});
it('more button does exist', () => {
expect(findMoreButton().exists()).toBe(true);
});
it('more button displays the correct wordage', () => {
expect(findMoreButton().text()).toBe('and 1 more');
});
describe('when more button clicked', () => {
beforeEach(() => {
findMoreButton().trigger('click');
});
it('more button no longer exists', () => {
expect(findMoreButton().exists()).toBe(false);
});
it('all emails are displayed', () => {
expect(wrapper.text()).toBe(
'a@gitlab.com, b@gitlab.com, c@gitlab.com, and d@gitlab.com will be notified of your comment.',
);
});
});
});
});
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import createStore from '~/notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
......@@ -19,7 +19,7 @@ describe('issue_note_form component', () => {
let props;
const createComponentWrapper = () => {
return shallowMount(NoteForm, {
return mount(NoteForm, {
store,
propsData: props,
});
......
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