Commit 01f1788a authored by Coung Ngo's avatar Coung Ngo

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
parent ecfc5247
...@@ -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