Commit 0e59f6ae authored by Denys Mishunov's avatar Denys Mishunov

Merge branch 'nfriend-enhance-ref-selector-for-tag-creation' into 'master'

Enhance ref_selector component to allow for tag creation

See merge request gitlab-org/gitlab!55193
parents a393e491 517f6844
...@@ -22,7 +22,6 @@ import RefResultsSection from './ref_results_section.vue'; ...@@ -22,7 +22,6 @@ import RefResultsSection from './ref_results_section.vue';
export default { export default {
name: 'RefSelector', name: 'RefSelector',
store: createStore(),
components: { components: {
GlDropdown, GlDropdown,
GlDropdownDivider, GlDropdownDivider,
...@@ -61,6 +60,13 @@ export default { ...@@ -61,6 +60,13 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
/** The validation state of this component. */
state: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { data() {
return { return {
...@@ -104,6 +110,16 @@ export default { ...@@ -104,6 +110,16 @@ export default {
showSectionHeaders() { showSectionHeaders() {
return this.enabledRefTypes.length > 1; return this.enabledRefTypes.length > 1;
}, },
toggleButtonClass() {
return { 'gl-inset-border-1-red-500!': !this.state };
},
footerSlotProps() {
return {
isLoading: this.isLoading,
matches: this.matches,
query: this.lastQuery,
};
},
}, },
watch: { watch: {
// Keep the Vuex store synchronized if the parent // Keep the Vuex store synchronized if the parent
...@@ -117,6 +133,14 @@ export default { ...@@ -117,6 +133,14 @@ export default {
}, },
}, },
}, },
beforeCreate() {
// Setting the store here instead of using
// the built in `store` component option because
// we need each new `RefSelector` instance to
// create a new Vuex store instance.
// See https://github.com/vuejs/vuex/issues/414#issue-184491718.
this.$store = createStore();
},
created() { created() {
// This method is defined here instead of in `methods` // This method is defined here instead of in `methods`
// because we need to access the .cancel() method // because we need to access the .cancel() method
...@@ -124,7 +148,7 @@ export default { ...@@ -124,7 +148,7 @@ export default {
// made inaccessible by Vue. More info: // made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392 // https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() { this.debouncedSearch = debounce(function search() {
this.search(this.query); this.search();
}, SEARCH_DEBOUNCE_MS); }, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId); this.setProjectId(this.projectId);
...@@ -133,19 +157,20 @@ export default { ...@@ -133,19 +157,20 @@ export default {
'enabledRefTypes', 'enabledRefTypes',
() => { () => {
this.setEnabledRefTypes(this.enabledRefTypes); this.setEnabledRefTypes(this.enabledRefTypes);
this.search(this.query); this.search();
}, },
{ immediate: true }, { immediate: true },
); );
}, },
methods: { methods: {
...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef', 'search']), ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
...mapActions({ storeSearch: 'search' }),
focusSearchBox() { focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus(); this.$refs.searchBox.$el.querySelector('input').focus();
}, },
onSearchBoxEnter() { onSearchBoxEnter() {
this.debouncedSearch.cancel(); this.debouncedSearch.cancel();
this.search(this.query); this.search();
}, },
onSearchBoxInput() { onSearchBoxInput() {
this.debouncedSearch(); this.debouncedSearch();
...@@ -154,15 +179,20 @@ export default { ...@@ -154,15 +179,20 @@ export default {
this.setSelectedRef(ref); this.setSelectedRef(ref);
this.$emit('input', this.selectedRef); this.$emit('input', this.selectedRef);
}, },
search() {
this.storeSearch(this.query);
},
}, },
}; };
</script> </script>
<template> <template>
<gl-dropdown <gl-dropdown
v-bind="$attrs"
:header-text="i18n.dropdownHeader" :header-text="i18n.dropdownHeader"
:toggle-class="toggleButtonClass"
class="ref-selector" class="ref-selector"
v-bind="$attrs"
v-on="$listeners"
@shown="focusSearchBox" @shown="focusSearchBox"
> >
<template #button-content> <template #button-content>
...@@ -242,5 +272,9 @@ export default { ...@@ -242,5 +272,9 @@ export default {
/> />
</template> </template>
</template> </template>
<template #footer>
<slot name="footer" v-bind="footerSlotProps"></slot>
</template>
</gl-dropdown> </gl-dropdown>
</template> </template>
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Ref selector component footer slot passes the expected slot props 1`] = `
Object {
"isLoading": false,
"matches": Object {
"branches": Object {
"error": null,
"list": Array [
Object {
"default": false,
"name": "add_images_and_changes",
},
Object {
"default": false,
"name": "conflict-contains-conflict-markers",
},
Object {
"default": false,
"name": "deleted-image-test",
},
Object {
"default": false,
"name": "diff-files-image-to-symlink",
},
Object {
"default": false,
"name": "diff-files-symlink-to-image",
},
Object {
"default": false,
"name": "markdown",
},
Object {
"default": true,
"name": "master",
},
],
"totalCount": 123,
},
"commits": Object {
"error": null,
"list": Array [
Object {
"name": "b83d6e39",
"subtitle": "Merge branch 'branch-merged' into 'master'",
"value": "b83d6e391c22777fca1ed3012fce84f633d7fed0",
},
],
"totalCount": 1,
},
"tags": Object {
"error": null,
"list": Array [
Object {
"name": "v1.1.1",
},
Object {
"name": "v1.1.0",
},
Object {
"name": "v1.0.0",
},
],
"totalCount": 456,
},
},
"query": "abcd1234",
}
`;
import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlDropdown, GlIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { merge, last } from 'lodash';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import { ENTER_KEY } from '~/lib/utils/keys'; import { ENTER_KEY } from '~/lib/utils/keys';
...@@ -34,26 +35,30 @@ describe('Ref selector component', () => { ...@@ -34,26 +35,30 @@ describe('Ref selector component', () => {
let commitApiCallSpy; let commitApiCallSpy;
let requestSpies; let requestSpies;
const createComponent = (props = {}, attrs = {}) => { const createComponent = (mountOverrides = {}) => {
wrapper = mount(RefSelector, { wrapper = mount(
propsData: { RefSelector,
projectId, merge(
value: '', {
...props, propsData: {
}, projectId,
attrs, value: '',
listeners: { },
// simulate a parent component v-model binding listeners: {
input: (selectedRef) => { // simulate a parent component v-model binding
wrapper.setProps({ value: selectedRef }); input: (selectedRef) => {
wrapper.setProps({ value: selectedRef });
},
},
stubs: {
GlSearchBoxByType: true,
},
localVue,
store: createStore(),
}, },
}, mountOverrides,
stubs: { ),
GlSearchBoxByType: true, );
},
localVue,
store: createStore(),
});
}; };
beforeEach(() => { beforeEach(() => {
...@@ -183,7 +188,7 @@ describe('Ref selector component', () => { ...@@ -183,7 +188,7 @@ describe('Ref selector component', () => {
const id = 'git-ref'; const id = 'git-ref';
beforeEach(() => { beforeEach(() => {
createComponent({}, { id }); createComponent({ attrs: { id } });
return waitForRequests(); return waitForRequests();
}); });
...@@ -197,7 +202,7 @@ describe('Ref selector component', () => { ...@@ -197,7 +202,7 @@ describe('Ref selector component', () => {
const preselectedRef = fixtures.branches[0].name; const preselectedRef = fixtures.branches[0].name;
beforeEach(() => { beforeEach(() => {
createComponent({ value: preselectedRef }); createComponent({ propsData: { value: preselectedRef } });
return waitForRequests(); return waitForRequests();
}); });
...@@ -611,7 +616,7 @@ describe('Ref selector component', () => { ...@@ -611,7 +616,7 @@ describe('Ref selector component', () => {
`( `(
'only calls $reqsCalled requests when $enabledRefTypes are enabled', 'only calls $reqsCalled requests when $enabledRefTypes are enabled',
async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => { async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => {
createComponent({ enabledRefTypes }); createComponent({ propsData: { enabledRefTypes } });
await waitForRequests(); await waitForRequests();
...@@ -621,7 +626,7 @@ describe('Ref selector component', () => { ...@@ -621,7 +626,7 @@ describe('Ref selector component', () => {
); );
it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => { it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => {
createComponent({ enabledRefTypes: [REF_TYPE_COMMITS] }); createComponent({ propsData: { enabledRefTypes: [REF_TYPE_COMMITS] } });
updateQuery('abcd1234'); updateQuery('abcd1234');
await waitForRequests(); await waitForRequests();
...@@ -632,7 +637,7 @@ describe('Ref selector component', () => { ...@@ -632,7 +637,7 @@ describe('Ref selector component', () => {
}); });
it('triggers another search if enabled ref types change', async () => { it('triggers another search if enabled ref types change', async () => {
createComponent({ enabledRefTypes: [REF_TYPE_BRANCHES] }); createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES] } });
await waitForRequests(); await waitForRequests();
expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
...@@ -648,7 +653,7 @@ describe('Ref selector component', () => { ...@@ -648,7 +653,7 @@ describe('Ref selector component', () => {
}); });
it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => { it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => {
createComponent({ enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] }); createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } });
updateQuery('abcd1234'); updateQuery('abcd1234');
await waitForRequests(); await waitForRequests();
...@@ -670,7 +675,7 @@ describe('Ref selector component', () => { ...@@ -670,7 +675,7 @@ describe('Ref selector component', () => {
`( `(
'hides section headers if a single ref type is enabled', 'hides section headers if a single ref type is enabled',
async ({ enabledRefType, findVisibleSection, findHiddenSections }) => { async ({ enabledRefType, findVisibleSection, findHiddenSections }) => {
createComponent({ enabledRefTypes: [enabledRefType] }); createComponent({ propsData: { enabledRefTypes: [enabledRefType] } });
updateQuery('abcd1234'); updateQuery('abcd1234');
await waitForRequests(); await waitForRequests();
...@@ -682,4 +687,70 @@ describe('Ref selector component', () => { ...@@ -682,4 +687,70 @@ describe('Ref selector component', () => {
}, },
); );
}); });
describe('validation state', () => {
const invalidClass = 'gl-inset-border-1-red-500!';
const isInvalidClassApplied = () => wrapper.find(GlDropdown).props('toggleClass')[invalidClass];
describe('valid state', () => {
describe('when the state prop is not provided', () => {
it('does not render a red border', () => {
createComponent();
expect(isInvalidClassApplied()).toBe(false);
});
});
describe('when the state prop is true', () => {
it('does not render a red border', () => {
createComponent({ propsData: { state: true } });
expect(isInvalidClassApplied()).toBe(false);
});
});
});
describe('invalid state', () => {
it('renders the dropdown with a red border if the state prop is false', () => {
createComponent({ propsData: { state: false } });
expect(isInvalidClassApplied()).toBe(true);
});
});
});
describe('footer slot', () => {
const footerContent = 'This is the footer content';
const createFooter = jest.fn().mockImplementation(function createMockFooter() {
return this.$createElement('div', { attrs: { 'data-testid': 'footer-content' } }, [
footerContent,
]);
});
beforeEach(() => {
createComponent({
scopedSlots: { footer: createFooter },
});
updateQuery('abcd1234');
return waitForRequests();
});
afterEach(() => {
createFooter.mockClear();
});
it('allows custom content to be shown at the bottom of the dropdown using the footer slot', () => {
expect(wrapper.find(`[data-testid="footer-content"]`).text()).toBe(footerContent);
});
it('passes the expected slot props', () => {
// The createFooter function gets called every time one of the scoped properties
// is updated. For the sake of this test, we'll just test the last call, which
// represents the final state of the slot props.
const lastCallProps = last(createFooter.mock.calls)[0];
expect(lastCallProps).toMatchSnapshot();
});
});
}); });
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