Commit d48bce3a authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '207134-add-new-mentions-component-to-issues' into 'master'

Add new mentions component to Issues textareas

See merge request gitlab-org/gitlab!32671
parents 0c6c3d3a 01f1788a
...@@ -9,13 +9,15 @@ export default class GLForm { ...@@ -9,13 +9,15 @@ export default class GLForm {
this.form = form; this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input'); this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
// Disable autocomplete for keywords which do not have dataSources available // Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => { Object.keys(this.enableGFM).forEach(item => {
if (item !== 'emojis') { if (item !== 'emojis' && !dataSources[item]) {
this.enableGFM[item] = Boolean(dataSources[item]); this.enableGFM[item] = false;
} }
}); });
// Before we start, we should clean up any previous data for this form // Before we start, we should clean up any previous data for this form
this.destroy(); this.destroy();
// Set up the form // Set up the form
......
...@@ -3,18 +3,19 @@ import { escape } from 'lodash'; ...@@ -3,18 +3,19 @@ import { escape } from 'lodash';
import Tribute from 'tributejs'; import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils'; import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
/** /**
* Creates the HTML template for each row of the mentions dropdown. * Creates the HTML template for each row of the mentions dropdown.
* *
* @param original An object from the array returned from the `autocomplete_sources/members` API * @param original - An object from the array returned from the `autocomplete_sources/members` API
* @returns {string} An HTML template * @returns {string} - An HTML template
*/ */
function menuItemTemplate({ original }) { function menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
gl-display-inline-flex gl-align-items-center gl-justify-content-center`; gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url const avatarTag = original.avatar_url
? `<img ? `<img
...@@ -48,6 +49,7 @@ export default { ...@@ -48,6 +49,7 @@ export default {
}, },
data() { data() {
return { return {
assignees: undefined,
members: undefined, members: undefined,
}; };
}, },
...@@ -76,19 +78,37 @@ export default { ...@@ -76,19 +78,37 @@ export default {
*/ */
getMembers(inputText, processValues) { getMembers(inputText, processValues) {
if (this.members) { if (this.members) {
processValues(this.members); processValues(this.getFilteredMembers());
} else if (this.dataSources.members) { } else if (this.dataSources.members) {
axios axios
.get(this.dataSources.members) .get(this.dataSources.members)
.then(response => { .then(response => {
this.members = response.data; this.members = response.data;
processValues(response.data); processValues(this.getFilteredMembers());
}) })
.catch(() => {}); .catch(() => {});
} else { } else {
processValues([]); processValues([]);
} }
}, },
getFilteredMembers() {
const fullText = this.$slots.default[0].elm.value;
if (!this.assignees) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
}
if (fullText.startsWith('/assign @')) {
return this.members.filter(member => !this.assignees.includes(member.username));
}
if (fullText.startsWith('/unassign @')) {
return this.members.filter(member => this.assignees.includes(member.username));
}
return this.members;
},
}, },
render(createElement) { render(createElement) {
return createElement('div', this.$slots.default); return createElement('div', this.$slots.default);
......
...@@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm'; ...@@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash'; import { unescape } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility'; import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash'; import Flash from '~/flash';
import GLForm from '../../../gl_form'; import GLForm from '~/gl_form';
import markdownHeader from './header.vue'; import MarkdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue'; import MarkdownToolbar from './toolbar.vue';
import icon from '../icon.vue'; import Icon from '../icon.vue';
import GlMentions from '~/vue_shared/components/gl_mentions.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export default { export default {
components: { components: {
markdownHeader, GlMentions,
markdownToolbar, MarkdownHeader,
icon, MarkdownToolbar,
Icon,
Suggestions, Suggestions,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
isSubmitting: { isSubmitting: {
type: Boolean, type: Boolean,
...@@ -159,12 +163,10 @@ export default { ...@@ -159,12 +163,10 @@ export default {
}, },
}, },
mounted() { mounted() {
/* // GLForm class handles all the toolbar buttons
GLForm class handles all the toolbar buttons
*/
return new GLForm($(this.$refs['gl-form']), { return new GLForm($(this.$refs['gl-form']), {
emojis: this.enableAutocomplete, emojis: this.enableAutocomplete,
members: this.enableAutocomplete, members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
issues: this.enableAutocomplete, issues: this.enableAutocomplete,
mergeRequests: this.enableAutocomplete, mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete, epics: this.enableAutocomplete,
...@@ -243,7 +245,10 @@ export default { ...@@ -243,7 +245,10 @@ export default {
/> />
<div v-show="!previewMarkdown" class="md-write-holder"> <div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop"> <div class="zen-backdrop">
<gl-mentions v-if="glFeatures.tributeAutocomplete">
<slot name="textarea"></slot> <slot name="textarea"></slot>
</gl-mentions>
<slot v-else name="textarea"></slot>
<a <a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#" href="#"
......
...@@ -46,6 +46,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -46,6 +46,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
end end
before_action only: :show do before_action only: :show do
......
...@@ -15,7 +15,10 @@ RSpec.describe 'GFM autocomplete EE', :js do ...@@ -15,7 +15,10 @@ RSpec.describe 'GFM autocomplete EE', :js do
context 'assignees' do context 'assignees' do
let(:issue_assignee) { create(:issue, project: project) } let(:issue_assignee) { create(:issue, project: project) }
describe 'when tribute_autocomplete feature flag is off' do
before do before do
stub_feature_flags(tribute_autocomplete: false)
issue_assignee.update(assignees: [user]) issue_assignee.update(assignees: [user])
sign_in(user) sign_in(user)
...@@ -40,4 +43,35 @@ RSpec.describe 'GFM autocomplete EE', :js do ...@@ -40,4 +43,35 @@ RSpec.describe 'GFM autocomplete EE', :js do
expect(users).not_to have_content(another_user.username) expect(users).not_to have_content(another_user.username)
end end
end end
describe 'when tribute_autocomplete feature flag is on' do
before do
stub_feature_flags(tribute_autocomplete: true)
issue_assignee.update(assignees: [user])
sign_in(user)
visit project_issue_path(project, issue_assignee)
wait_for_requests
end
it 'only lists users who are currently assigned to the issue when using /unassign' do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/una')
end
find('.atwho-view li', text: '/unassign')
note.native.send_keys(:tab)
note.native.send_keys(:right)
wait_for_requests
users = find('.tribute-container ul')
expect(users).to have_content(user.username)
expect(users).not_to have_content(another_user.username)
end
end
end
end end
...@@ -14,7 +14,10 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -14,7 +14,10 @@ RSpec.describe 'GFM autocomplete', :js do
let(:label) { create(:label, project: project, title: 'special+') } let(:label) { create(:label, project: project, title: 'special+') }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
describe 'when tribute_autocomplete feature flag is off' do
before do before do
stub_feature_flags(tribute_autocomplete: false)
project.add_maintainer(user) project.add_maintainer(user)
project.add_maintainer(user_xss) project.add_maintainer(user_xss)
...@@ -453,6 +456,188 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -453,6 +456,188 @@ RSpec.describe 'GFM autocomplete', :js do
it_behaves_like 'autocomplete suggestions' it_behaves_like 'autocomplete suggestions'
end end
end
describe 'when tribute_autocomplete feature flag is on' do
before do
stub_feature_flags(tribute_autocomplete: true)
project.add_maintainer(user)
project.add_maintainer(user_xss)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
it 'updates issue description with GFM reference' do
find('.js-issuable-edit').click
wait_for_requests
simulate_input('#issue-description', "@#{user.name[0...3]}")
wait_for_requests
find('.tribute-container .highlight').click
click_button 'Save changes'
wait_for_requests
expect(find('.description')).to have_content(user.to_reference)
end
it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@ev')
end
wait_for_requests
expect(page).to have_selector('.tribute-container')
page.within '.tribute-container ul' do
expect(find('li').text).to have_content(user_xss.username)
end
end
it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('testing')
find('#note-body').native.send_keys('@')
end
expect(page).not_to have_selector('.tribute-container')
end
it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
wait_for_requests
expect(find('.tribute-container ul')).to have_selector('.highlight:first-of-type')
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('')
simulate_input('#note-body', "@#{user.name[0...8]}")
end
expect(page).to have_selector('.tribute-container')
wait_for_requests
expect(find('.tribute-container')).to have_content(user.name)
end
context 'if a selected value has special characters' do
it "shows dropdown after a new line" do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('test')
note.native.send_keys(:enter)
note.native.send_keys(:enter)
note.native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
end
it "does not show dropdown when preceded with a special character" do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys("@")
end
expect(page).to have_selector('.tribute-container')
page.within '.timeline-content-form' do
note.native.send_keys("@")
end
expect(page).to have_selector('.tribute-container', visible: false)
end
it 'doesn\'t wrap for assignee values' do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys("@#{user.username[0]}")
end
user_item = find('.tribute-container li', text: user.username)
expect_to_wrap(false, user_item, note, user.username)
end
it 'doesn\'t open autocomplete after non-word character' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys("@#{user.username[0..2]}!")
end
expect(page).not_to have_selector('.tribute-container')
end
it 'triggers autocomplete after selecting a quick action' do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/as')
end
find('.atwho-view li', text: '/assign')
note.native.send_keys(:tab)
note.native.send_keys(:right)
wait_for_requests
user_item = find('.tribute-container li', text: user.username)
expect(user_item).to have_content(user.username)
end
end
context 'assignees' do
let(:issue_assignee) { create(:issue, project: project) }
let(:unassigned_user) { create(:user) }
before do
issue_assignee.update(assignees: [user])
project.add_maintainer(unassigned_user)
end
it 'lists users who are currently not assigned to the issue when using /assign' do
visit project_issue_path(project, issue_assignee)
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/as')
end
find('.atwho-view li', text: '/assign')
note.native.send_keys(:tab)
note.native.send_keys(:right)
wait_for_requests
expect(find('.tribute-container ul')).not_to have_content(user.username)
expect(find('.tribute-container ul')).to have_content(unassigned_user.username)
end
end
end
private private
......
...@@ -36,12 +36,35 @@ RSpec.describe 'Member autocomplete', :js do ...@@ -36,12 +36,35 @@ RSpec.describe 'Member autocomplete', :js do
let(:noteable) { create(:issue, author: author, project: project) } let(:noteable) { create(:issue, author: author, project: project) }
before do before do
stub_feature_flags(tribute_autocomplete: false)
visit project_issue_path(project, noteable) visit project_issue_path(project, noteable)
end end
include_examples "open suggestions when typing @", 'issue' include_examples "open suggestions when typing @", 'issue'
end end
describe 'when tribute_autocomplete feature flag is on' do
context 'adding a new note on a Issue' do
let(:noteable) { create(:issue, author: author, project: project) }
before do
stub_feature_flags(tribute_autocomplete: true)
visit project_issue_path(project, noteable)
page.within('.new-note') do
find('#note-body').send_keys('@')
end
end
it 'suggests noteable author and note author' do
page.within('.tribute-container', visible: true) do
expect(page).to have_content(author.username)
expect(page).to have_content(note.author.username)
end
end
end
end
context 'adding a new note on a Merge Request' do context 'adding a new note on a Merge Request' do
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
let(:noteable) do let(:noteable) do
......
...@@ -11507,10 +11507,10 @@ tr46@^2.0.2: ...@@ -11507,10 +11507,10 @@ tr46@^2.0.2:
dependencies: dependencies:
punycode "^2.1.1" punycode "^2.1.1"
tributejs@4.1.3: tributejs@5.1.3:
version "4.1.3" version "5.1.3"
resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-4.1.3.tgz#2e1be7d9a1e403ed4c394f91d859812267e4691c" resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae"
integrity sha512-+VUqyi8p7tCdaqCINCWHf95E2hJFMIML180BhplTpXNooz3E2r96AONXI9qO2Ru6Ugp7MsMPJjB+rnBq+hAmzA== integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==
trim-newlines@^1.0.0: trim-newlines@^1.0.0:
version "1.0.0" version "1.0.0"
......
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