From 01f1788a354cfaa0593e89b70196ffc914478502 Mon Sep 17 00:00:00 2001 From: Coung Ngo <cngo@gitlab.com> Date: Wed, 20 May 2020 15:45:29 +0100 Subject: [PATCH] Add new mentions component to Issues As part of the refactor from at.js to tribute, the new tribute component was added to the Issues description, note and comment textareas --- app/assets/javascripts/gl_form.js | 6 +- .../vue_shared/components/gl_mentions.vue | 30 +- .../vue_shared/components/markdown/field.vue | 31 +- app/controllers/projects/issues_controller.rb | 1 + .../issues/gfm_autocomplete_ee_spec.rb | 64 +- package.json | 2 +- spec/features/issues/gfm_autocomplete_spec.rb | 733 +++++++++++------- .../participants_autocomplete_spec.rb | 23 + yarn.lock | 8 +- 9 files changed, 584 insertions(+), 314 deletions(-) diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 0b7735a7db9e3..28f1e3afd3de3 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -9,13 +9,15 @@ export default class GLForm { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM }; + // Disable autocomplete for keywords which do not have dataSources available const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; Object.keys(this.enableGFM).forEach(item => { - if (item !== 'emojis') { - this.enableGFM[item] = Boolean(dataSources[item]); + if (item !== 'emojis' && !dataSources[item]) { + this.enableGFM[item] = false; } }); + // Before we start, we should clean up any previous data for this form this.destroy(); // Set up the form diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index a7fba5e760bb6..0ef4f1eda2747 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -3,18 +3,19 @@ import { escape } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_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. * - * @param original An object from the array returned from the `autocomplete_sources/members` API - * @returns {string} An HTML template + * @param original - An object from the array returned from the `autocomplete_sources/members` API + * @returns {string} - An HTML template */ function menuItemTemplate({ original }) { const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; 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 ? `<img @@ -48,6 +49,7 @@ export default { }, data() { return { + assignees: undefined, members: undefined, }; }, @@ -76,19 +78,37 @@ export default { */ getMembers(inputText, processValues) { if (this.members) { - processValues(this.members); + processValues(this.getFilteredMembers()); } else if (this.dataSources.members) { axios .get(this.dataSources.members) .then(response => { this.members = response.data; - processValues(response.data); + processValues(this.getFilteredMembers()); }) .catch(() => {}); } else { 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) { return createElement('div', this.$slots.default); diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 0e05f4a462251..89844f07e7e8f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm'; import { unescape } from 'lodash'; import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; -import Flash from '../../../flash'; -import GLForm from '../../../gl_form'; -import markdownHeader from './header.vue'; -import markdownToolbar from './toolbar.vue'; -import icon from '../icon.vue'; +import Flash from '~/flash'; +import GLForm from '~/gl_form'; +import MarkdownHeader from './header.vue'; +import MarkdownToolbar from './toolbar.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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; export default { components: { - markdownHeader, - markdownToolbar, - icon, + GlMentions, + MarkdownHeader, + MarkdownToolbar, + Icon, Suggestions, }, + mixins: [glFeatureFlagsMixin()], props: { isSubmitting: { type: Boolean, @@ -159,12 +163,10 @@ export default { }, }, mounted() { - /* - GLForm class handles all the toolbar buttons - */ + // GLForm class handles all the toolbar buttons return new GLForm($(this.$refs['gl-form']), { emojis: this.enableAutocomplete, - members: this.enableAutocomplete, + members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete, mergeRequests: this.enableAutocomplete, epics: this.enableAutocomplete, @@ -243,7 +245,10 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <slot name="textarea"></slot> + <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <slot name="textarea"></slot> + </gl-mentions> + <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" href="#" diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 693329848de59..72fea21b52b1c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -46,6 +46,7 @@ def set_issuables_index_only_actions before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) + push_frontend_feature_flag(:tribute_autocomplete, @project) end before_action only: :show do diff --git a/ee/spec/features/issues/gfm_autocomplete_ee_spec.rb b/ee/spec/features/issues/gfm_autocomplete_ee_spec.rb index 525d290f616bb..ed0cfaa4e6e74 100644 --- a/ee/spec/features/issues/gfm_autocomplete_ee_spec.rb +++ b/ee/spec/features/issues/gfm_autocomplete_ee_spec.rb @@ -15,29 +15,63 @@ context 'assignees' do let(:issue_assignee) { create(:issue, project: project) } - before do - issue_assignee.update(assignees: [user]) + describe 'when tribute_autocomplete feature flag is off' do + before do + stub_feature_flags(tribute_autocomplete: false) - sign_in(user) - visit project_issue_path(project, issue_assignee) + issue_assignee.update(assignees: [user]) - wait_for_requests + 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) + + wait_for_requests + + users = find('#at-view-users .atwho-view-ul') + expect(users).to have_content(user.username) + expect(users).not_to have_content(another_user.username) + end 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') + 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 - find('.atwho-view li', text: '/unassign') - note.native.send_keys(:tab) + 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 + wait_for_requests - users = find('#at-view-users .atwho-view-ul') - expect(users).to have_content(user.username) - expect(users).not_to have_content(another_user.username) + 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 diff --git a/package.json b/package.json index 7b2f268145f07..d8cc0a832a09d 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "tiptap": "^1.8.0", "tiptap-commands": "^1.4.0", "tiptap-extensions": "^1.8.0", - "tributejs": "4.1.3", + "tributejs": "5.1.3", "unfetch": "^4.1.0", "url-loader": "^3.0.0", "uuid": "8.1.0", diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 4a7e1ba99e9ec..eca9e75fed654 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -14,444 +14,629 @@ let(:label) { create(:label, project: project, title: 'special+') } let(:issue) { create(:issue, project: project) } - before do - project.add_maintainer(user) - project.add_maintainer(user_xss) + describe 'when tribute_autocomplete feature flag is off' do + before do + stub_feature_flags(tribute_autocomplete: false) - sign_in(user) - visit project_issue_path(project, issue) + project.add_maintainer(user) + project.add_maintainer(user_xss) - wait_for_requests - end + sign_in(user) + visit project_issue_path(project, issue) - it 'updates issue description with GFM reference' do - find('.js-issuable-edit').click + wait_for_requests + end - wait_for_requests + it 'updates issue description with GFM reference' do + find('.js-issuable-edit').click - simulate_input('#issue-description', "@#{user.name[0...3]}") + wait_for_requests - wait_for_requests + simulate_input('#issue-description', "@#{user.name[0...3]}") - find('.atwho-view .cur').click + wait_for_requests - click_button 'Save changes' + find('.atwho-view .cur').click - wait_for_requests + click_button 'Save changes' - expect(find('.description')).to have_content(user.to_reference) - end + wait_for_requests - it 'opens autocomplete menu when field starts with text' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') + expect(find('.description')).to have_content(user.to_reference) end - expect(page).to have_selector('.atwho-container') - end - - it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do - create(:issue, project: project, title: issue_xss_title) + it 'opens autocomplete menu when field starts with text' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('@') + end - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('#') + expect(page).to have_selector('.atwho-container') end - wait_for_requests + it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do + create(:issue, project: project, title: issue_xss_title) + + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('#') + end - expect(page).to have_selector('.atwho-container') + wait_for_requests - page.within '.atwho-container #at-view-issues' do - expect(page.all('li').first.text).to include(issue_xss_title) - end - end + expect(page).to have_selector('.atwho-container') - 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') + page.within '.atwho-container #at-view-issues' do + expect(page.all('li').first.text).to include(issue_xss_title) + end end - wait_for_requests + 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 - expect(page).to have_selector('.atwho-container') + wait_for_requests - page.within '.atwho-container #at-view-users' do - expect(find('li').text).to have_content(user_xss.username) + expect(page).to have_selector('.atwho-container') + + page.within '.atwho-container #at-view-users' do + expect(find('li').text).to have_content(user_xss.username) + end end - end - it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do - create(:milestone, project: project, title: milestone_xss_title) + it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do + create(:milestone, project: project, title: milestone_xss_title) - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('%') - end + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('%') + end - wait_for_requests + wait_for_requests - expect(page).to have_selector('.atwho-container') + expect(page).to have_selector('.atwho-container') - page.within '.atwho-container #at-view-milestones' do - expect(find('li').text).to have_content('alert milestone') + page.within '.atwho-container #at-view-milestones' do + expect(find('li').text).to have_content('alert milestone') + end 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('@') + 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('.atwho-view') end - expect(page).not_to have_selector('.atwho-view') - end + it 'doesnt select the first item for non-assignee dropdowns' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys(':') + end - it 'doesnt select the first item for non-assignee dropdowns' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(':') + expect(page).to have_selector('.atwho-container') + + wait_for_requests + + expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type') end - expect(page).to have_selector('.atwho-container') + it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do + note = find('#note-body') - wait_for_requests + # Number. + page.within '.timeline-content-form' do + note.native.send_keys('7:') + end - expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type') - end + expect(page).not_to have_selector('.atwho-view') - it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do - note = find('#note-body') + # ASCII letter. + page.within '.timeline-content-form' do + note.set('') + note.native.send_keys('w:') + end - # Number. - page.within '.timeline-content-form' do - note.native.send_keys('7:') - end + expect(page).not_to have_selector('.atwho-view') - expect(page).not_to have_selector('.atwho-view') + # Non-ASCII letter. + page.within '.timeline-content-form' do + note.set('') + note.native.send_keys('Ð:') + end - # ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('w:') + expect(page).not_to have_selector('.atwho-view') end - expect(page).not_to have_selector('.atwho-view') + it 'selects the first item for assignee dropdowns' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('@') + end - # Non-ASCII letter. - page.within '.timeline-content-form' do - note.set('') - note.native.send_keys('Ð:') - end + expect(page).to have_selector('.atwho-container') - expect(page).not_to have_selector('.atwho-view') - end + wait_for_requests - it 'selects the first item for assignee dropdowns' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('@') + expect(find('#at-view-users')).to have_selector('.cur:first-of-type') end - expect(page).to have_selector('.atwho-container') + 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 - wait_for_requests + expect(page).to have_selector('.atwho-container') - expect(find('#at-view-users')).to have_selector('.cur:first-of-type') - end + wait_for_requests - 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]}") + expect(find('#at-view-users')).to have_content(user.name) end - expect(page).to have_selector('.atwho-container') + it 'selects the first item for non-assignee dropdowns if a query is entered' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys(':1') + end - wait_for_requests + expect(page).to have_selector('.atwho-container') - expect(find('#at-view-users')).to have_content(user.name) - end + wait_for_requests - it 'selects the first item for non-assignee dropdowns if a query is entered' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(':1') + expect(find('#at-view-58')).to have_selector('.cur:first-of-type') end - expect(page).to have_selector('.atwho-container') + context 'if a selected value has special characters' do + it 'wraps the result in double quotes' do + note = find('#note-body') + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('') + simulate_input('#note-body', "~#{label.title[0]}") + end - wait_for_requests + label_item = find('.atwho-view li', text: label.title) - expect(find('#at-view-58')).to have_selector('.cur:first-of-type') - end + expect_to_wrap(true, label_item, note, label.title) + end - context 'if a selected value has special characters' do - it 'wraps the result in double quotes' do - note = find('#note-body') - page.within '.timeline-content-form' do - find('#note-body').native.send_keys('') - simulate_input('#note-body', "~#{label.title[0]}") + 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('.atwho-container') end - label_item = find('.atwho-view li', text: label.title) + 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_to_wrap(true, label_item, note, label.title) - end + expect(page).to have_selector('.atwho-container') - 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('@') + page.within '.timeline-content-form' do + note.native.send_keys("@") + end + + expect(page).to have_selector('.atwho-container', visible: false) end - expect(page).to have_selector('.atwho-container') - end + it "does not throw an error if no labels exist" do + note = find('#note-body') + page.within '.timeline-content-form' do + note.native.send_keys('~') + 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("@") + expect(page).to have_selector('.atwho-container', visible: false) end - expect(page).to have_selector('.atwho-container') + 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 - page.within '.timeline-content-form' do - note.native.send_keys("@") + user_item = find('.atwho-view li', text: user.username) + + expect_to_wrap(false, user_item, note, user.username) end - expect(page).to have_selector('.atwho-container', visible: false) - end + it 'doesn\'t wrap for emoji values' do + note = find('#note-body') + page.within '.timeline-content-form' do + note.native.send_keys(":cartwheel_") + end - it "does not throw an error if no labels exist" do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('~') + emoji_item = find('.atwho-view li', text: 'cartwheel_tone1') + + expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') end - expect(page).to have_selector('.atwho-container', visible: false) - 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 - 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]}") + expect(page).not_to have_selector('.atwho-view') end - user_item = find('.atwho-view li', text: user.username) + it 'doesn\'t open autocomplete if there is no space before' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys("hello:#{user.username[0..2]}") + end + + expect(page).not_to have_selector('.atwho-view') + end - expect_to_wrap(false, user_item, note, user.username) + 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) + + user_item = find('.atwho-view li', text: user.username) + expect(user_item).to have_content(user.username) + end end - it 'doesn\'t wrap for emoji values' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys(":cartwheel_") + 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 - emoji_item = find('.atwho-view li', text: 'cartwheel_tone1') + it 'lists users who are currently not assigned to the issue when using /assign' do + visit project_issue_path(project, issue_assignee) - expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') - end + note = find('#note-body') + page.within '.timeline-content-form' do + note.native.send_keys('/as') + 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]}!") + find('.atwho-view li', text: '/assign') + note.native.send_keys(:tab) + + wait_for_requests + + expect(find('#at-view-users .atwho-view-ul')).not_to have_content(user.username) + expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) end - expect(page).not_to have_selector('.atwho-view') + it 'shows dropdown on new issue form' do + visit new_project_issue_path(project) + + textarea = find('#issue_description') + textarea.native.send_keys('/ass') + find('.atwho-view li', text: '/assign') + textarea.native.send_keys(:tab) + + expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) + expect(find('#at-view-users .atwho-view-ul')).to have_content(user.username) + end end - it 'doesn\'t open autocomplete if there is no space before' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys("hello:#{user.username[0..2]}") + context 'labels' do + it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do + create(:label, project: project, title: label_xss_title) + + note = find('#note-body') + + # It should show all the labels on "~". + type(note, '~') + + wait_for_requests + + page.within '.atwho-container #at-view-labels' do + expect(find('.atwho-view-ul').text).to have_content('alert label') + end end - expect(page).not_to have_selector('.atwho-view') - end + it 'allows colons when autocompleting scoped labels' do + create(:label, project: project, title: 'scoped:label') - it 'triggers autocomplete after selecting a quick action' do - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/as') + note = find('#note-body') + type(note, '~scoped:') + + wait_for_requests + + page.within '.atwho-container #at-view-labels' do + expect(find('.atwho-view-ul').text).to have_content('scoped:label') + end end - find('.atwho-view li', text: '/assign') - note.native.send_keys(:tab) + it 'allows colons when autocompleting scoped labels with double colons' do + create(:label, project: project, title: 'scoped::label') - user_item = find('.atwho-view li', text: user.username) - expect(user_item).to have_content(user.username) - end - end + note = find('#note-body') + type(note, '~scoped::') - context 'assignees' do - let(:issue_assignee) { create(:issue, project: project) } - let(:unassigned_user) { create(:user) } + wait_for_requests - before do - issue_assignee.update(assignees: [user]) + page.within '.atwho-container #at-view-labels' do + expect(find('.atwho-view-ul').text).to have_content('scoped::label') + end + end + + it 'allows spaces when autocompleting multi-word labels' do + create(:label, project: project, title: 'Accepting merge requests') + + note = find('#note-body') + type(note, '~Accepting merge') + + wait_for_requests - project.add_maintainer(unassigned_user) + page.within '.atwho-container #at-view-labels' do + expect(find('.atwho-view-ul').text).to have_content('Accepting merge requests') + end + end + + it 'only autocompletes the latest label' do + create(:label, project: project, title: 'Accepting merge requests') + create(:label, project: project, title: 'Accepting job applicants') + + note = find('#note-body') + type(note, '~Accepting merge requests foo bar ~Accepting job') + + wait_for_requests + + page.within '.atwho-container #at-view-labels' do + expect(find('.atwho-view-ul').text).to have_content('Accepting job applicants') + end + end + + it 'does not autocomplete labels if no tilde is typed' do + create(:label, project: project, title: 'Accepting merge requests') + + note = find('#note-body') + type(note, 'Accepting merge') + + wait_for_requests + + expect(page).not_to have_css('.atwho-container #at-view-labels') + end end - it 'lists users who are currently not assigned to the issue when using /assign' do - visit project_issue_path(project, issue_assignee) + shared_examples 'autocomplete suggestions' do + it 'suggests objects correctly' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys(object.class.reference_prefix) + end - note = find('#note-body') - page.within '.timeline-content-form' do - note.native.send_keys('/as') + page.within '.atwho-container' do + expect(page).to have_content(object.title) + + find('ul li').click + end + + expect(find('.new-note #note-body').value).to include(expected_body) end + end - find('.atwho-view li', text: '/assign') - note.native.send_keys(:tab) + context 'issues' do + let(:object) { issue } + let(:expected_body) { object.to_reference } - wait_for_requests + it_behaves_like 'autocomplete suggestions' + end - expect(find('#at-view-users .atwho-view-ul')).not_to have_content(user.username) - expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) + context 'merge requests' do + let(:object) { create(:merge_request, source_project: project) } + let(:expected_body) { object.to_reference } + + it_behaves_like 'autocomplete suggestions' end - it 'shows dropdown on new issue form' do - visit new_project_issue_path(project) + context 'project snippets' do + let!(:object) { create(:project_snippet, project: project, title: 'code snippet') } + let(:expected_body) { object.to_reference } + + it_behaves_like 'autocomplete suggestions' + end - textarea = find('#issue_description') - textarea.native.send_keys('/ass') - find('.atwho-view li', text: '/assign') - textarea.native.send_keys(:tab) + context 'label' do + let!(:object) { label } + let(:expected_body) { object.title } - expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username) - expect(find('#at-view-users .atwho-view-ul')).to have_content(user.username) + it_behaves_like 'autocomplete suggestions' + end + + context 'milestone' do + let!(:object) { create(:milestone, project: project) } + let(:expected_body) { object.to_reference } + + it_behaves_like 'autocomplete suggestions' end end - context 'labels' do - it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do - create(:label, project: project, title: label_xss_title) + describe 'when tribute_autocomplete feature flag is on' do + before do + stub_feature_flags(tribute_autocomplete: true) - note = find('#note-body') + project.add_maintainer(user) + project.add_maintainer(user_xss) - # It should show all the labels on "~". - type(note, '~') + sign_in(user) + visit project_issue_path(project, issue) wait_for_requests - - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('alert label') - end end - it 'allows colons when autocompleting scoped labels' do - create(:label, project: project, title: 'scoped:label') - - note = find('#note-body') - type(note, '~scoped:') + it 'updates issue description with GFM reference' do + find('.js-issuable-edit').click wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('scoped:label') - end - end + simulate_input('#issue-description', "@#{user.name[0...3]}") - it 'allows colons when autocompleting scoped labels with double colons' do - create(:label, project: project, title: 'scoped::label') + wait_for_requests - note = find('#note-body') - type(note, '~scoped::') + find('.tribute-container .highlight').click + + click_button 'Save changes' wait_for_requests - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('scoped::label') - end + expect(find('.description')).to have_content(user.to_reference) end - it 'allows spaces when autocompleting multi-word labels' do - create(:label, project: project, title: 'Accepting merge requests') + it 'opens autocomplete menu when field starts with text' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys('@') + end - note = find('#note-body') - type(note, '~Accepting merge') + 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 - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('Accepting merge 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 'only autocompletes the latest label' do - create(:label, project: project, title: 'Accepting merge requests') - create(:label, project: project, title: 'Accepting job applicants') + 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 - note = find('#note-body') - type(note, '~Accepting merge requests foo bar ~Accepting job') + 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 - page.within '.atwho-container #at-view-labels' do - expect(find('.atwho-view-ul').text).to have_content('Accepting job applicants') - end + expect(find('.tribute-container ul')).to have_selector('.highlight:first-of-type') end - it 'does not autocomplete labels if no tilde is typed' do - create(:label, project: project, title: 'Accepting merge requests') + 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 - note = find('#note-body') - type(note, 'Accepting merge') + expect(page).to have_selector('.tribute-container') wait_for_requests - expect(page).not_to have_css('.atwho-container #at-view-labels') + expect(find('.tribute-container')).to have_content(user.name) end - end - shared_examples 'autocomplete suggestions' do - it 'suggests objects correctly' do - page.within '.timeline-content-form' do - find('#note-body').native.send_keys(object.class.reference_prefix) + 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 - page.within '.atwho-container' do - expect(page).to have_content(object.title) + 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') - find('ul li').click + page.within '.timeline-content-form' do + note.native.send_keys("@") + end + + expect(page).to have_selector('.tribute-container', visible: false) end - expect(find('.new-note #note-body').value).to include(expected_body) - end - 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 - context 'issues' do - let(:object) { issue } - let(:expected_body) { object.to_reference } + user_item = find('.tribute-container li', text: user.username) - it_behaves_like 'autocomplete suggestions' - end + expect_to_wrap(false, user_item, note, user.username) + end - context 'merge requests' do - let(:object) { create(:merge_request, source_project: project) } - let(:expected_body) { object.to_reference } + 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 - it_behaves_like 'autocomplete suggestions' - end + expect(page).not_to have_selector('.tribute-container') + end - context 'project snippets' do - let!(:object) { create(:project_snippet, project: project, title: 'code snippet') } - let(:expected_body) { object.to_reference } + 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 - it_behaves_like 'autocomplete suggestions' - end + find('.atwho-view li', text: '/assign') + note.native.send_keys(:tab) + note.native.send_keys(:right) - context 'label' do - let!(:object) { label } - let(:expected_body) { object.title } + wait_for_requests - it_behaves_like 'autocomplete suggestions' - end + user_item = find('.tribute-container li', text: user.username) + expect(user_item).to have_content(user.username) + end + end - context 'milestone' do - let!(:object) { create(:milestone, project: project) } - let(:expected_body) { object.to_reference } + 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_behaves_like 'autocomplete suggestions' + 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 diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index 5c29ac870c04e..d6f23b21d655d 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -36,12 +36,35 @@ let(:noteable) { create(:issue, author: author, project: project) } before do + stub_feature_flags(tribute_autocomplete: false) visit project_issue_path(project, noteable) end include_examples "open suggestions when typing @", 'issue' 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 let(:project) { create(:project, :public, :repository) } let(:noteable) do diff --git a/yarn.lock b/yarn.lock index 50b0249d7fb1e..25620a753b9c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11507,10 +11507,10 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" -tributejs@4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-4.1.3.tgz#2e1be7d9a1e403ed4c394f91d859812267e4691c" - integrity sha512-+VUqyi8p7tCdaqCINCWHf95E2hJFMIML180BhplTpXNooz3E2r96AONXI9qO2Ru6Ugp7MsMPJjB+rnBq+hAmzA== +tributejs@5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae" + integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ== trim-newlines@^1.0.0: version "1.0.0" -- GitLab