import { GlButton, GlModal } from '@gitlab/ui'; import { nextTick } from 'vue'; import createFlash from '~/flash'; import Modal from '~/ide/components/new_dropdown/modal.vue'; import { createStore } from '~/ide/stores'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createEntriesFromPaths } from '../../helpers'; jest.mock('~/flash'); const NEW_NAME = 'babar'; describe('new file modal component', () => { const showModal = jest.fn(); const toggleModal = jest.fn(); let store; let wrapper; const findForm = () => wrapper.findByTestId('file-name-form'); const findGlModal = () => wrapper.findComponent(GlModal); const findInput = () => wrapper.findByTestId('file-name-field'); const findTemplateButtons = () => wrapper.findAllComponents(GlButton); const findTemplateButtonsModel = () => findTemplateButtons().wrappers.map((x) => ({ text: x.text(), variant: x.props('variant'), category: x.props('category'), })); const open = (type, path) => { // TODO: This component can not be passed props // We have to interact with the open() method? wrapper.vm.open(type, path); }; const triggerSubmitForm = () => { findForm().trigger('submit'); }; const triggerSubmitModal = () => { findGlModal().vm.$emit('primary'); }; const triggerCancel = () => { findGlModal().vm.$emit('cancel'); }; const mountComponent = () => { const GlModalStub = stubComponent(GlModal); jest.spyOn(GlModalStub.methods, 'show').mockImplementation(showModal); jest.spyOn(GlModalStub.methods, 'toggle').mockImplementation(toggleModal); wrapper = shallowMountExtended(Modal, { store, stubs: { GlModal: GlModalStub, }, // We need to attach to document for "focus" to work attachTo: document.body, }); }; beforeEach(() => { store = createStore(); Object.assign( store.state.entries, createEntriesFromPaths([ 'README.md', 'src', 'src/deleted.js', 'src/parent_dir', 'src/parent_dir/foo.js', ]), ); Object.assign(store.state.entries['src/deleted.js'], { deleted: true }); jest.spyOn(store, 'dispatch').mockImplementation(); }); afterEach(() => { store = null; wrapper.destroy(); document.body.innerHTML = ''; }); describe('default', () => { beforeEach(async () => { mountComponent(); // Not necessarily needed, but used to ensure that nothing extra is happening after the tick await nextTick(); }); it('renders modal', () => { expect(findGlModal().props()).toMatchObject({ actionCancel: { attributes: [{ variant: 'default' }], text: 'Cancel', }, actionPrimary: { attributes: [{ variant: 'confirm' }], text: 'Create file', }, actionSecondary: null, size: 'lg', modalId: 'ide-new-entry', title: 'Create new file', }); }); it('renders name label', () => { expect(wrapper.find('label').text()).toBe('Name'); }); it('renders template buttons', () => { const actual = findTemplateButtonsModel(); expect(actual.length).toBeGreaterThan(0); expect(actual).toEqual( store.getters['fileTemplates/templateTypes'].map((template) => ({ category: 'secondary', text: template.name, variant: 'dashed', })), ); }); // These negative ".not.toHaveBeenCalled" assertions complement the positive "toHaveBeenCalled" // assertions that show up later in this spec. Without these, we're not guaranteed the "act" // actually caused the change in behavior. it('does not dispatch actions by default', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); it('does not trigger modal by default', () => { expect(showModal).not.toHaveBeenCalled(); expect(toggleModal).not.toHaveBeenCalled(); }); it('does not focus input by default', () => { expect(document.activeElement).toBe(document.body); }); }); describe.each` entryType | path | modalTitle | btnTitle | showsFileTemplates | inputValue | inputPlaceholder ${'tree'} | ${''} | ${'Create new directory'} | ${'Create directory'} | ${false} | ${''} | ${'dir/'} ${'blob'} | ${''} | ${'Create new file'} | ${'Create file'} | ${true} | ${''} | ${'dir/file_name'} ${'blob'} | ${'foo/bar'} | ${'Create new file'} | ${'Create file'} | ${true} | ${'foo/bar/'} | ${'dir/file_name'} `( 'when opened as $entryType with path "$path"', ({ entryType, path, modalTitle, btnTitle, showsFileTemplates, inputValue, inputPlaceholder, }) => { beforeEach(async () => { mountComponent(); open(entryType, path); await nextTick(); }); it('sets modal props', () => { expect(findGlModal().props()).toMatchObject({ title: modalTitle, actionPrimary: { attributes: [{ variant: 'confirm' }], text: btnTitle, }, }); }); it('sets input attributes', () => { expect(findInput().element.value).toBe(inputValue); expect(findInput().attributes('placeholder')).toBe(inputPlaceholder); }); it(`shows file templates: ${showsFileTemplates}`, () => { const actual = findTemplateButtonsModel().length > 0; expect(actual).toBe(showsFileTemplates); }); it('shows modal', () => { expect(showModal).toHaveBeenCalled(); }); it('focus on input', () => { expect(document.activeElement).toBe(findInput().element); }); it('resets when canceled', async () => { triggerCancel(); await nextTick(); // Resets input value expect(findInput().element.value).toBe(''); // Resets to blob mode expect(findGlModal().props('title')).toBe('Create new file'); }); }, ); describe.each` modalType | name | expectedName ${'blob'} | ${'foo/bar.js'} | ${'foo/bar.js'} ${'blob'} | ${'foo /bar.js'} | ${'foo/bar.js'} ${'tree'} | ${'foo/dir'} | ${'foo/dir'} ${'tree'} | ${'foo /dir'} | ${'foo/dir'} `('when submitting as $modalType with "$name"', ({ modalType, name, expectedName }) => { describe('when using the modal primary button', () => { beforeEach(async () => { mountComponent(); open(modalType, ''); await nextTick(); findInput().setValue(name); triggerSubmitModal(); }); it('triggers createTempEntry action', () => { expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { name: expectedName, type: modalType, }); }); }); describe('when triggering form submit (pressing enter)', () => { beforeEach(async () => { mountComponent(); open(modalType, ''); await nextTick(); findInput().setValue(name); triggerSubmitForm(); }); it('triggers createTempEntry action', () => { expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { name: expectedName, type: modalType, }); }); }); }); describe('when creating from template type', () => { beforeEach(async () => { mountComponent(); open('blob', 'some_dir'); await nextTick(); // Set input, then trigger button findInput().setValue('some_dir/foo.js'); findTemplateButtons().at(1).vm.$emit('click'); }); it('triggers createTempEntry action', () => { const { name: expectedName } = store.getters['fileTemplates/templateTypes'][1]; expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { name: `some_dir/${expectedName}`, type: 'blob', }); }); it('toggles modal', () => { expect(toggleModal).toHaveBeenCalled(); }); }); describe.each` origPath | title | inputValue | inputSelectionStart ${'src/parent_dir'} | ${'Rename folder'} | ${'src/parent_dir'} | ${'src/'.length} ${'README.md'} | ${'Rename file'} | ${'README.md'} | ${0} `('when renaming for $origPath', ({ origPath, title, inputValue, inputSelectionStart }) => { beforeEach(async () => { mountComponent(); open('rename', origPath); await nextTick(); }); it('sets modal props for renaming', () => { expect(findGlModal().props()).toMatchObject({ title, actionPrimary: { attributes: [{ variant: 'confirm' }], text: title, }, }); }); it('sets input value', () => { expect(findInput().element.value).toBe(inputValue); }); it(`does not show file templates`, () => { expect(findTemplateButtonsModel()).toHaveLength(0); }); it('shows modal when renaming', () => { expect(showModal).toHaveBeenCalled(); }); it('focus on input when renaming', () => { expect(document.activeElement).toBe(findInput().element); }); it('selects name part of the input', () => { expect(findInput().element.selectionStart).toBe(inputSelectionStart); expect(findInput().element.selectionEnd).toBe(origPath.length); }); describe('when renames is submitted successfully', () => { describe('when using the modal primary button', () => { beforeEach(() => { findInput().setValue(NEW_NAME); triggerSubmitModal(); }); it('dispatches renameEntry event', () => { expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { path: origPath, parentPath: '', name: NEW_NAME, }); }); it('does not trigger flash', () => { expect(createFlash).not.toHaveBeenCalled(); }); }); describe('when triggering form submit (pressing enter)', () => { beforeEach(() => { findInput().setValue(NEW_NAME); triggerSubmitForm(); }); it('dispatches renameEntry event', () => { expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { path: origPath, parentPath: '', name: NEW_NAME, }); }); it('does not trigger flash', () => { expect(createFlash).not.toHaveBeenCalled(); }); }); }); }); describe('when renaming and file already exists', () => { beforeEach(async () => { mountComponent(); open('rename', 'src/parent_dir'); await nextTick(); // Set to something that already exists! findInput().setValue('src'); triggerSubmitModal(); }); it('creates flash', () => { expect(createFlash).toHaveBeenCalledWith({ message: 'The name "src" is already taken in this directory.', fadeTransition: false, addBodyClass: true, }); }); it('does not dispatch event', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); }); describe('when renaming and file has been deleted', () => { beforeEach(async () => { mountComponent(); open('rename', 'src/parent_dir/foo.js'); await nextTick(); findInput().setValue('src/deleted.js'); triggerSubmitModal(); }); it('does not create flash', () => { expect(createFlash).not.toHaveBeenCalled(); }); it('dispatches event', () => { expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { path: 'src/parent_dir/foo.js', name: 'deleted.js', parentPath: 'src', }); }); }); });