From 05f66d1342db228c1a659b4c58e15c890b522261 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Wed, 14 Feb 2018 10:16:04 +0100 Subject: Add new modal Vue component --- .../jobs/index/components/stop_jobs_modal.vue | 26 +-- .../javascripts/pages/admin/jobs/index/index.js | 37 ++-- .../javascripts/vue_shared/components/gl_modal.vue | 106 ++++++++++++ app/views/admin/jobs/index.html.haml | 7 +- changelogs/unreleased/winh-new-modal-component.yml | 5 + doc/development/fe_guide/components.md | 61 +++++++ doc/development/fe_guide/dropdowns.md | 33 +--- doc/development/fe_guide/img/gl-modal.png | Bin 0 -> 25893 bytes doc/development/fe_guide/index.md | 6 +- .../vue_shared/components/gl_modal_spec.js | 192 +++++++++++++++++++++ 10 files changed, 405 insertions(+), 68 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/gl_modal.vue create mode 100644 changelogs/unreleased/winh-new-modal-component.yml create mode 100644 doc/development/fe_guide/components.md create mode 100644 doc/development/fe_guide/img/gl-modal.png create mode 100644 spec/javascripts/vue_shared/components/gl_modal_spec.js diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 555725cbe12..ba1d8e4d8db 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,13 +1,13 @@ diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 0e004bd9174..31d58eabaaf 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -8,22 +8,23 @@ Vue.use(Translate); export default () => { const stopJobsButton = document.getElementById('stop-jobs-button'); - - // eslint-disable-next-line no-new - new Vue({ - el: '#stop-jobs-modal', - components: { - stopJobsModal, - }, - mounted() { - stopJobsButton.classList.remove('disabled'); - }, - render(createElement) { - return createElement('stop-jobs-modal', { - props: { - url: stopJobsButton.dataset.url, - }, - }); - }, - }); + if (stopJobsButton) { + // eslint-disable-next-line no-new + new Vue({ + el: '#stop-jobs-modal', + components: { + stopJobsModal, + }, + mounted() { + stopJobsButton.classList.remove('disabled'); + }, + render(createElement) { + return createElement('stop-jobs-modal', { + props: { + url: stopJobsButton.dataset.url, + }, + }); + }, + }); + } }; diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue new file mode 100644 index 00000000000..67c9181c7b1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -0,0 +1,106 @@ + + + diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index a01676d82a8..4e3e2f7a475 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -7,10 +7,9 @@ - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - .nav-controls - - if @all_builds.running_or_pending.any? - #stop-jobs-modal - + - if @all_builds.running_or_pending.any? + #stop-jobs-modal + .nav-controls %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal', target: '#stop-jobs-modal', url: cancel_all_admin_jobs_path } } diff --git a/changelogs/unreleased/winh-new-modal-component.yml b/changelogs/unreleased/winh-new-modal-component.yml new file mode 100644 index 00000000000..bcc0d489c88 --- /dev/null +++ b/changelogs/unreleased/winh-new-modal-component.yml @@ -0,0 +1,5 @@ +--- +title: Add new modal Vue component +merge_request: 17108 +author: +type: changed diff --git a/doc/development/fe_guide/components.md b/doc/development/fe_guide/components.md new file mode 100644 index 00000000000..66a8abe42f7 --- /dev/null +++ b/doc/development/fe_guide/components.md @@ -0,0 +1,61 @@ +# Components + +## Contents +* [Dropdowns](#dropdowns) +* [Modals](#modals) + +## Dropdowns + +See also the [corresponding UX guide](../ux_guide/components.md#dropdowns). + +### How to style a bootstrap dropdown +1. Use the HTML structure provided by the [docs][bootstrap-dropdowns] +1. Add a specific class to the top level `.dropdown` element + + + ```Haml + .dropdown.my-dropdown + %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false } + %span.dropdown-toggle-text + Toggle Dropdown + = icon('chevron-down') + + %ul.dropdown-menu + %li + %a + item! + ``` + + Or use the helpers + ```Haml + .dropdown.my-dropdown + = dropdown_toggle('Toogle!', { toggle: 'dropdown' }) + = dropdown_content + %li + %a + item! + ``` + +[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns + +## Modals + +See also the [corresponding UX guide](../ux_guide/components.md#modals). + +We have a reusable Vue component for modals: [vue_shared/components/gl-modal.vue](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/vue_shared/components/gl-modal.vue) + +Here is an example of how to use it: + +```html + + {{ s__('ModalExample|You’re about to let the dogs out.') }} + +``` + +![example modal](img/gl-modal.png) diff --git a/doc/development/fe_guide/dropdowns.md b/doc/development/fe_guide/dropdowns.md index 6314f8f38d2..e9d6244355c 100644 --- a/doc/development/fe_guide/dropdowns.md +++ b/doc/development/fe_guide/dropdowns.md @@ -1,32 +1 @@ -# Dropdowns - - -## How to style a bootstrap dropdown -1. Use the HTML structure provided by the [docs][bootstrap-dropdowns] -1. Add a specific class to the top level `.dropdown` element - - - ```Haml - .dropdown.my-dropdown - %button{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false } - %span.dropdown-toggle-text - Toggle Dropdown - = icon('chevron-down') - - %ul.dropdown-menu - %li - %a - item! - ``` - - Or use the helpers - ```Haml - .dropdown.my-dropdown - = dropdown_toggle('Toogle!', { toggle: 'dropdown' }) - = dropdown_content - %li - %a - item! - ``` - -[bootstrap-dropdowns]: https://getbootstrap.com/docs/3.3/javascript/#dropdowns +This page has moved [here](components.md#dropdowns). diff --git a/doc/development/fe_guide/img/gl-modal.png b/doc/development/fe_guide/img/gl-modal.png new file mode 100644 index 00000000000..47302e857bc Binary files /dev/null and b/doc/development/fe_guide/img/gl-modal.png differ diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index 72cb557d054..5c2e50e8c8e 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -77,8 +77,10 @@ Axios specific practices and gotchas. ## [Icons](icons.md) How we use SVG for our Icons. -## [Dropdowns](dropdowns.md) -How we use dropdowns. +## [Components](components.md) + +How we use UI components. + --- ## Style Guides diff --git a/spec/javascripts/vue_shared/components/gl_modal_spec.js b/spec/javascripts/vue_shared/components/gl_modal_spec.js new file mode 100644 index 00000000000..d6148cb785b --- /dev/null +++ b/spec/javascripts/vue_shared/components/gl_modal_spec.js @@ -0,0 +1,192 @@ +import Vue from 'vue'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const modalComponent = Vue.extend(GlModal); + +describe('GlModal', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('props', () => { + describe('with id', () => { + const props = { + id: 'my-modal', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('assigns the id to the modal', () => { + expect(vm.$el.id).toBe(props.id); + }); + }); + + describe('without id', () => { + beforeEach(() => { + vm = mountComponent(modalComponent, { }); + }); + + it('does not add an id attribute to the modal', () => { + expect(vm.$el.hasAttribute('id')).toBe(false); + }); + }); + + describe('with headerTitleText', () => { + const props = { + headerTitleText: 'my title text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + expect(modalTitle.innerHTML.trim()).toBe(props.headerTitleText); + }); + }); + + describe('with footerPrimaryButtonVariant', () => { + const props = { + footerPrimaryButtonVariant: 'danger', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button class', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + expect(primaryButton).toHaveClass(`btn-${props.footerPrimaryButtonVariant}`); + }); + }); + + describe('with footerPrimaryButtonText', () => { + const props = { + footerPrimaryButtonText: 'my button text', + }; + + beforeEach(() => { + vm = mountComponent(modalComponent, props); + }); + + it('sets the primary button text', () => { + const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText); + }); + }); + }); + + it('works with data-toggle="modal"', (done) => { + setFixtures(` + + + `); + + const modalContainer = document.getElementById('modal-container'); + const modalButton = document.getElementById('modal-button'); + vm = mountComponent(modalComponent, { + id: 'my-modal', + }, modalContainer); + $(vm.$el).on('shown.bs.modal', () => done()); + + modalButton.click(); + }); + + describe('methods', () => { + const dummyEvent = 'not really an event'; + + beforeEach(() => { + vm = mountComponent(modalComponent, { }); + spyOn(vm, '$emit'); + }); + + describe('emitCancel', () => { + it('emits a cancel event', () => { + vm.emitCancel(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('cancel', dummyEvent); + }); + }); + + describe('emitSubmit', () => { + it('emits a submit event', () => { + vm.emitSubmit(dummyEvent); + + expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent); + }); + }); + }); + + describe('slots', () => { + const slotContent = 'this should go into the slot'; + const modalWithSlot = (slotName) => { + let template; + if (slotName) { + template = ` + + + + `; + } else { + template = `${slotContent}`; + } + + return Vue.extend({ + components: { + GlModal, + }, + template, + }); + }; + + describe('default slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot()); + }); + + it('sets the modal body', () => { + const modalBody = vm.$el.querySelector('.modal-body'); + expect(modalBody.innerHTML).toBe(slotContent); + }); + }); + + describe('header slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('header')); + }); + + it('sets the modal header', () => { + const modalHeader = vm.$el.querySelector('.modal-header'); + expect(modalHeader.innerHTML).toBe(slotContent); + }); + }); + + describe('title slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('title')); + }); + + it('sets the modal title', () => { + const modalTitle = vm.$el.querySelector('.modal-title'); + expect(modalTitle.innerHTML).toBe(slotContent); + }); + }); + + describe('footer slot', () => { + beforeEach(() => { + vm = mountComponent(modalWithSlot('footer')); + }); + + it('sets the modal footer', () => { + const modalFooter = vm.$el.querySelector('.modal-footer'); + expect(modalFooter.innerHTML).toBe(slotContent); + }); + }); + }); +}); -- cgit v1.2.1