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';
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