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';
export default {
name: 'RefSelector',
store: createStore(),
components: {
GlDropdown,
GlDropdownDivider,
......@@ -61,6 +60,13 @@ export default {
required: false,
default: () => ({}),
},
/** The validation state of this component. */
state: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
......@@ -104,6 +110,16 @@ export default {
showSectionHeaders() {
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: {
// Keep the Vuex store synchronized if the parent
......@@ -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() {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
......@@ -124,7 +148,7 @@ export default {
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() {
this.search(this.query);
this.search();
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
......@@ -133,19 +157,20 @@ export default {
'enabledRefTypes',
() => {
this.setEnabledRefTypes(this.enabledRefTypes);
this.search(this.query);
this.search();
},
{ immediate: true },
);
},
methods: {
...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef', 'search']),
...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']),
...mapActions({ storeSearch: 'search' }),
focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus();
},
onSearchBoxEnter() {
this.debouncedSearch.cancel();
this.search(this.query);
this.search();
},
onSearchBoxInput() {
this.debouncedSearch();
......@@ -154,15 +179,20 @@ export default {
this.setSelectedRef(ref);
this.$emit('input', this.selectedRef);
},
search() {
this.storeSearch(this.query);
},
},
};
</script>
<template>
<gl-dropdown
v-bind="$attrs"
:header-text="i18n.dropdownHeader"
:toggle-class="toggleButtonClass"
class="ref-selector"
v-bind="$attrs"
v-on="$listeners"
@shown="focusSearchBox"
>
<template #button-content>
......@@ -242,5 +272,9 @@ export default {
/>
</template>
</template>
<template #footer>
<slot name="footer" v-bind="footerSlotProps"></slot>
</template>
</gl-dropdown>
</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 axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { merge, last } from 'lodash';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import { ENTER_KEY } from '~/lib/utils/keys';
......@@ -34,26 +35,30 @@ describe('Ref selector component', () => {
let commitApiCallSpy;
let requestSpies;
const createComponent = (props = {}, attrs = {}) => {
wrapper = mount(RefSelector, {
propsData: {
projectId,
value: '',
...props,
},
attrs,
listeners: {
// simulate a parent component v-model binding
input: (selectedRef) => {
wrapper.setProps({ value: selectedRef });
const createComponent = (mountOverrides = {}) => {
wrapper = mount(
RefSelector,
merge(
{
propsData: {
projectId,
value: '',
},
listeners: {
// simulate a parent component v-model binding
input: (selectedRef) => {
wrapper.setProps({ value: selectedRef });
},
},
stubs: {
GlSearchBoxByType: true,
},
localVue,
store: createStore(),
},
},
stubs: {
GlSearchBoxByType: true,
},
localVue,
store: createStore(),
});
mountOverrides,
),
);
};
beforeEach(() => {
......@@ -183,7 +188,7 @@ describe('Ref selector component', () => {
const id = 'git-ref';
beforeEach(() => {
createComponent({}, { id });
createComponent({ attrs: { id } });
return waitForRequests();
});
......@@ -197,7 +202,7 @@ describe('Ref selector component', () => {
const preselectedRef = fixtures.branches[0].name;
beforeEach(() => {
createComponent({ value: preselectedRef });
createComponent({ propsData: { value: preselectedRef } });
return waitForRequests();
});
......@@ -611,7 +616,7 @@ describe('Ref selector component', () => {
`(
'only calls $reqsCalled requests when $enabledRefTypes are enabled',
async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => {
createComponent({ enabledRefTypes });
createComponent({ propsData: { enabledRefTypes } });
await waitForRequests();
......@@ -621,7 +626,7 @@ describe('Ref selector component', () => {
);
it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => {
createComponent({ enabledRefTypes: [REF_TYPE_COMMITS] });
createComponent({ propsData: { enabledRefTypes: [REF_TYPE_COMMITS] } });
updateQuery('abcd1234');
await waitForRequests();
......@@ -632,7 +637,7 @@ describe('Ref selector component', () => {
});
it('triggers another search if enabled ref types change', async () => {
createComponent({ enabledRefTypes: [REF_TYPE_BRANCHES] });
createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES] } });
await waitForRequests();
expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
......@@ -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 () => {
createComponent({ enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] });
createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } });
updateQuery('abcd1234');
await waitForRequests();
......@@ -670,7 +675,7 @@ describe('Ref selector component', () => {
`(
'hides section headers if a single ref type is enabled',
async ({ enabledRefType, findVisibleSection, findHiddenSections }) => {
createComponent({ enabledRefTypes: [enabledRefType] });
createComponent({ propsData: { enabledRefTypes: [enabledRefType] } });
updateQuery('abcd1234');
await waitForRequests();
......@@ -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