Commit 46bac3c3 authored by Sam Bigelow's avatar Sam Bigelow Committed by Mike Greiling

Extract input of related issues form

This will be used in blocking merge requests later.
parent cf0fd305
<script>
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import { GlLoadingIcon } from '@gitlab/ui';
import issueToken from './issue_token.vue';
import { autoCompleteTextMap, inputPlaceholderTextMap } from '../constants';
const SPACE_FACTOR = 1;
import RelatedIssuableInput from './related_issuable_input.vue';
import { issuableTypesMap } from '../constants';
export default {
name: 'AddIssuableForm',
components: {
issueToken,
GlLoadingIcon,
RelatedIssuableInput,
},
props: {
inputValue: {
......@@ -40,162 +36,52 @@ export default {
issuableType: {
type: String,
required: false,
default: 'issue',
default: issuableTypesMap.ISSUE,
},
},
data() {
return {
isInputFocused: false,
isAutoCompleteOpen: false,
};
},
computed: {
inputPlaceholder() {
const { issuableType, allowAutoComplete } = this;
const allowAutoCompleteText = autoCompleteTextMap[allowAutoComplete][issuableType];
return `${inputPlaceholderTextMap[issuableType]}${allowAutoCompleteText}`;
},
isSubmitButtonDisabled() {
return (
(this.inputValue.length === 0 && this.pendingReferences.length === 0) || this.isSubmitting
);
},
allowAutoComplete() {
return Object.keys(this.autoCompleteSources).length > 0;
},
},
mounted() {
const $input = $(this.$refs.input);
if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
epics: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
}
this.$refs.input.focus();
},
beforeDestroy() {
const $input = $(this.$refs.input);
$input.off('shown-issues.atwho');
$input.off('hidden-issues.atwho');
$input.off('inserted-issues.atwho', this.onInput);
},
methods: {
onInput() {
const { value } = this.$refs.input;
const caretPos = $(this.$refs.input).caret('pos');
const rawRefs = value.split(/\s/);
let touchedReference;
let position = 0;
const untouchedRawRefs = rawRefs
.filter(ref => {
let isTouched = false;
if (caretPos >= position && caretPos <= position + ref.length) {
touchedReference = ref;
isTouched = true;
}
// `+ SPACE_FACTOR` to factor in the missing space we split at earlier
position = position + ref.length + SPACE_FACTOR;
return !isTouched;
})
.filter(ref => ref.trim().length > 0);
this.$emit('addIssuableFormInput', {
newValue: value,
untouchedRawReferences: untouchedRawRefs,
touchedReference,
caretPos,
});
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
// Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) {
const { value } = this.$refs.input;
this.$emit('addIssuableFormBlur', value);
}
},
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
},
onInputWrapperClick() {
this.$refs.input.focus();
},
onPendingIssuableRemoveRequest(params) {
this.$emit('pendingIssuableRemoveRequest', params);
},
onFormSubmit() {
const { value } = this.$refs.input;
this.$emit('addIssuableFormSubmit', value);
this.$emit('addIssuableFormSubmit', this.$refs.relatedIssuableInput.$refs.input.value);
},
onFormCancel() {
this.$emit('addIssuableFormCancel');
},
onAddIssuableFormInput(params) {
this.$emit('addIssuableFormInput', params);
},
onAddIssuableFormBlur(params) {
this.$emit('addIssuableFormBlur', params);
},
},
};
</script>
<template>
<form @submit.prevent="onFormSubmit">
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
class="add-issuable-form-input-wrapper form-control"
role="button"
@click="onInputWrapperClick"
>
<ul class="add-issuable-form-input-token-list">
<!--
We need to ensure this key changes any time the pendingReferences array is updated
else two consecutive pending ref strings in an array with the same name will collide
and cause odd behavior when one is removed.
-->
<li
v-for="(reference, index) in pendingReferences"
:key="`related-issues-token-${reference}`"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item"
>
<issue-token
:id-key="index"
:display-reference="reference"
:can-remove="true"
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
/>
</li>
<li class="add-issuable-form-input-list-item">
<input
ref="input"
:value="inputValue"
:placeholder="inputPlaceholder"
type="text"
class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keyup.escape.exact="onFormCancel"
/>
</li>
</ul>
</div>
<related-issuable-input
ref="relatedIssuableInput"
:focus-on-mount="true"
:references="pendingReferences"
:path-id-separator="pathIdSeparator"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
:auto-complete-options="{ issues: true, epics: true }"
:issuable-type="issuableType"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
@formCancel="onFormCancel"
@addIssuableFormBlur="onAddIssuableFormBlur"
@addIssuableFormInput="onAddIssuableFormInput"
/>
<div class="add-issuable-form-actions clearfix">
<button
ref="addButton"
......
<script>
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import issueToken from './issue_token.vue';
import { autoCompleteTextMap, inputPlaceholderTextMap, issuableTypesMap } from '../constants';
const SPACE_FACTOR = 1;
export default {
name: 'RelatedIssuableInput',
components: {
issueToken,
},
props: {
references: {
type: Array,
required: false,
default: () => [],
},
pathIdSeparator: {
type: String,
required: true,
},
inputValue: {
type: String,
required: false,
default: '',
},
focusOnMount: {
type: Boolean,
required: false,
default: false,
},
autoCompleteSources: {
type: Object,
required: false,
default: () => ({}),
},
autoCompleteOptions: {
type: Object,
required: false,
default: () => ({}),
},
issuableType: {
type: String,
required: false,
default: issuableTypesMap.ISSUE,
},
},
data() {
return {
isInputFocused: false,
isAutoCompleteOpen: false,
};
},
computed: {
inputPlaceholder() {
const { issuableType, allowAutoComplete } = this;
const allowAutoCompleteText = autoCompleteTextMap[allowAutoComplete][issuableType];
return `${inputPlaceholderTextMap[issuableType]}${allowAutoCompleteText}`;
},
allowAutoComplete() {
return Object.keys(this.autoCompleteSources).length > 0;
},
},
mounted() {
this.setupAutoComplete();
if (this.focusOnMount) {
this.$refs.input.focus();
}
},
beforeDestroy() {
const $input = $(this.$refs.input);
$input.off('shown-issues.atwho');
$input.off('hidden-issues.atwho');
$input.off('inserted-issues.atwho', this.onInput);
},
methods: {
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
},
onInputWrapperClick() {
this.$refs.input.focus();
},
onInput() {
const { value } = this.$refs.input;
const caretPos = this.$refs.input.selectionStart;
const rawRefs = value.split(/\s/);
let touchedReference;
let position = 0;
const untouchedRawRefs = rawRefs
.filter(ref => {
let isTouched = false;
if (caretPos >= position && caretPos <= position + ref.length) {
touchedReference = ref;
isTouched = true;
}
position = position + ref.length + SPACE_FACTOR;
return !isTouched;
})
.filter(ref => ref.trim().length > 0);
this.$emit('addIssuableFormInput', {
newValue: value,
untouchedRawReferences: untouchedRawRefs,
touchedReference,
caretPos,
});
},
onBlur() {
this.isInputFocused = false;
// Avoid tokenizing partial input when clicking an autocomplete item
if (!this.isAutoCompleteOpen) {
const { value } = this.$refs.input;
this.$emit('addIssuableFormBlur', value);
}
},
onFocus() {
this.isInputFocused = true;
},
setupAutoComplete() {
const $input = $(this.$refs.input);
if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, this.autoCompleteOptions);
}
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
},
onIssuableFormWrapperClick() {
this.$refs.input.focus();
},
},
};
</script>
<template>
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
class="add-issuable-form-input-wrapper form-control"
role="button"
@click="onIssuableFormWrapperClick"
>
<ul class="add-issuable-form-input-token-list">
<!--
We need to ensure this key changes any time the pendingReferences array is updated
else two consecutive pending ref strings in an array with the same name will collide
and cause odd behavior when one is removed.
-->
<li
v-for="(reference, index) in references"
:key="`related-issues-token-${reference}`"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item"
>
<issue-token
:id-key="index"
:display-reference="reference"
:can-remove="true"
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="
params => {
$emit('pendingIssuableRemoveRequest', params);
}
"
/>
</li>
<li class="add-issuable-form-input-list-item">
<input
ref="input"
:value="inputValue"
:placeholder="inputPlaceholder"
type="text"
class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keyup.escape.exact="$emit('addIssuableFormCancel')"
/>
</li>
</ul>
</div>
</template>
......@@ -32,6 +32,7 @@ import {
relatedIssuesRemoveErrorMap,
pathIndeterminateErrorMap,
addRelatedIssueErrorMap,
issuableTypesMap,
} from '../constants';
export default {
......@@ -67,7 +68,7 @@ export default {
issuableType: {
type: String,
required: false,
default: 'issue',
default: issuableTypesMap.ISSUE,
},
allowAutoComplete: {
type: Boolean,
......
import { __ } from '~/locale';
export const issuableTypesMap = {
ISSUE: 'issue',
EPIC: 'epic',
};
export const autoCompleteTextMap = {
true: {
issue: __(' or <#issue id>'),
epic: __(' or <#epic id>'),
[issuableTypesMap.ISSUE]: __(' or <#issue id>'),
[issuableTypesMap.EPIC]: __(' or <#epic id>'),
},
false: {
issue: '',
epic: '',
[issuableTypesMap.ISSUE]: '',
[issuableTypesMap.EPIC]: '',
},
};
export const inputPlaceholderTextMap = {
issue: __('Paste issue link'),
epic: __('Paste epic link'),
[issuableTypesMap.ISSUE]: __('Paste issue link'),
[issuableTypesMap.EPIC]: __('Paste epic link'),
};
export const relatedIssuesRemoveErrorMap = {
issue: __('An error occurred while removing issues.'),
epic: __('An error occurred while removing epics.'),
[issuableTypesMap.ISSUE]: __('An error occurred while removing issues.'),
[issuableTypesMap.EPIC]: __('An error occurred while removing epics.'),
};
export const pathIndeterminateErrorMap = {
issue: __('We could not determine the path to remove the issue'),
epic: __('We could not determine the path to remove the epic'),
[issuableTypesMap.ISSUE]: __('We could not determine the path to remove the issue'),
[issuableTypesMap.EPIC]: __('We could not determine the path to remove the epic'),
};
export const addRelatedIssueErrorMap = {
issue: __("We can't find an issue that matches what you are looking for."),
epic: __("We can't find an epic that matches what you are looking for."),
[issuableTypesMap.ISSUE]: __("We can't find an issue that matches what you are looking for."),
[issuableTypesMap.EPIC]: __("We can't find an epic that matches what you are looking for."),
};
/**
......@@ -37,8 +42,8 @@ export const addRelatedIssueErrorMap = {
* them inside i18n functions.
*/
export const issuableIconMap = {
issue: 'issues',
epic: 'epic',
[issuableTypesMap.ISSUE]: 'issues',
[issuableTypesMap.EPIC]: 'epic',
};
/**
......@@ -47,6 +52,6 @@ export const issuableIconMap = {
* them inside i18n functions.
*/
export const issuableQaClassMap = {
issue: 'qa-add-issues-button',
epic: 'qa-add-epics-button',
[issuableTypesMap.ISSUE]: 'qa-add-issues-button',
[issuableTypesMap.EPIC]: 'qa-add-epics-button',
};
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { issuableTypesMap } from 'ee/related_issues/constants';
import RelatedIssuableInput from 'ee/related_issues/components/related_issuable_input.vue';
jest.mock('ee_else_ce/gfm_auto_complete', () => ({
__esModule: true,
default() {
return {
constructor() {},
setup() {},
};
},
}));
describe('RelatedIssuableInput', () => {
let propsData;
beforeEach(() => {
propsData = {
inputValue: '',
references: [],
pathIdSeparator: '#',
issuableType: issuableTypesMap.issue,
autoCompleteSources: {
issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`,
},
};
});
describe('autocomplete', () => {
describe('with autoCompleteSources', () => {
it('shows placeholder text', () => {
const wrapper = shallowMount(RelatedIssuableInput, { propsData });
expect(wrapper.find({ ref: 'input' }).element.placeholder).toBe(
'Paste issue link or <#issue id>',
);
});
it('has GfmAutoComplete', () => {
const wrapper = shallowMount(RelatedIssuableInput, { propsData });
expect(wrapper.vm.gfmAutoComplete).toBeDefined();
});
});
describe('with no autoCompleteSources', () => {
it('shows placeholder text', () => {
const wrapper = shallowMount(RelatedIssuableInput, {
propsData: {
...propsData,
references: ['!1', '!2'],
},
});
expect(wrapper.find({ ref: 'input' }).element.value).toBe('');
});
it('does not have GfmAutoComplete', () => {
const wrapper = shallowMount(RelatedIssuableInput, {
propsData: {
...propsData,
autoCompleteSources: {},
},
});
expect(wrapper.vm.gfmAutoComplete).not.toBeDefined();
});
});
});
describe('focus', () => {
it('when clicking anywhere on the input wrapper it should focus the input', () => {
const wrapper = shallowMount(RelatedIssuableInput, {
propsData: {
...propsData,
references: ['foo', 'bar'],
},
});
wrapper.find('li').trigger('click');
expect(document.activeElement).toBe(wrapper.find({ ref: 'input' }).element);
});
});
describe('when filling in the input', () => {
it('emits addIssuableFormInput with data', () => {
const wrapper = shallowMount(RelatedIssuableInput, {
propsData,
});
wrapper.vm.$emit = jest.fn();
const newInputValue = 'filling in things';
const untouchedRawReferences = newInputValue.trim().split(/\s/);
const touchedReference = untouchedRawReferences.pop();
const input = wrapper.find({ ref: 'input' });
input.element.value = newInputValue;
input.element.selectionStart = newInputValue.length;
input.element.selectionEnd = newInputValue.length;
input.trigger('input');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue,
caretPos: newInputValue.length,
untouchedRawReferences,
touchedReference,
});
});
});
});
import $ from 'jquery';
import Vue from 'vue';
import addIssuableForm from 'ee/related_issues/components/add_issuable_form.vue';
......@@ -90,7 +89,7 @@ describe('AddIssuableForm', () => {
});
it('should put input value in place', () => {
expect(vm.$refs.input.value).toEqual(inputValue);
expect(vm.$el.querySelector('.js-add-issuable-form-input').value).toEqual(inputValue);
});
it('should render pending issuables items', () => {
......@@ -102,163 +101,20 @@ describe('AddIssuableForm', () => {
});
});
describe('when submitting', () => {
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
pendingReferences: [issuable1.reference, issuable2.reference],
isSubmitting: true,
pathIdSeparator,
},
}).$mount();
});
it('should have disabled submit button with loading icon', () => {
expect(vm.$refs.addButton.disabled).toBe(true);
expect(vm.$refs.loadingIcon).toBeDefined();
});
});
});
describe('autocomplete', () => {
describe('with autoCompleteSources', () => {
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount();
});
it('shows placeholder text', () => {
expect(vm.$refs.input.placeholder).toEqual('Paste issue link or <#issue id>');
});
it('has GfmAutoComplete', () => {
expect(vm.gfmAutoComplete).toBeDefined();
});
});
describe('with no autoCompleteSources', () => {
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
autoCompleteSources: {},
pathIdSeparator,
},
}).$mount();
});
it('shows placeholder text', () => {
expect(vm.$refs.input.placeholder).toEqual('Paste issue link');
});
it('does not have GfmAutoComplete', () => {
expect(vm.gfmAutoComplete).not.toBeDefined();
});
});
});
describe('methods', () => {
beforeEach(() => {
const el = document.createElement('div');
// We need to append to body to get focus tests working
document.body.appendChild(el);
it('when submitting pending issues', () => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
pendingIssuables: [issuable1],
autoCompleteSources: {
issues: '/fake/issues/path',
},
inputValue: 'foo #123',
pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator,
},
}).$mount(el);
});
it('when clicking somewhere on the input wrapper should focus the input', done => {
vm.onInputWrapperClick();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(true);
expect(document.activeElement).toEqual(vm.$refs.input);
done();
});
});
});
it('when filling in the input', () => {
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
const untouchedRawReferences = newInputValue.trim().split(/\s/);
const touchedReference = untouchedRawReferences.pop();
vm.$refs.input.value = newInputValue;
vm.onInput();
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
newValue: newInputValue,
caretPos: newInputValue.length,
untouchedRawReferences,
touchedReference,
});
});
vm.$mount();
it('when blurring the input', done => {
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
vm.onBlur();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.issuableFormWrapper.classList.contains('focus')).toEqual(false);
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormBlur', newInputValue);
done();
});
});
});
it('when using the autocomplete', done => {
const $input = $(vm.$refs.input);
vm.gfmAutoComplete.loadData($input, '#', [
{
id: 1,
iid: 111,
title: 'foo',
},
]);
$input
.val('#')
.trigger('input')
.trigger('click');
$('.atwho-container li').trigger('click');
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$refs.input.value).toEqual('');
done();
});
});
});
it('when submitting pending issues', () => {
spyOn(vm, '$emit');
const newInputValue = 'filling in things';
vm.$refs.input.value = newInputValue;
const inputEl = vm.$el.querySelector('.js-add-issuable-form-input');
inputEl.value = newInputValue;
vm.onFormSubmit();
expect(vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', newInputValue);
......
......@@ -22,6 +22,9 @@ module QA
view 'ee/app/assets/javascripts/related_issues/components/add_issuable_form.vue' do
element :add_issue_button
end
view 'ee/app/assets/javascripts/related_issues/components/related_issuable_input.vue' do
element :add_issue_input
end
......
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