Commit 517f6844 authored by Nathan Friend's avatar Nathan Friend Committed by Denys Mishunov

Add footer slot and new events to ref selector

This commit updates the ref selector component with some new abilities:

- New footer slot
- New events
- Validation state
- Allow for multiple instances on the same page
parent a85fd642
...@@ -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