Add new modal Vue component
This commit is contained in:
parent
dd633bc188
commit
05f66d1342
10 changed files with 405 additions and 68 deletions
|
@ -1,13 +1,13 @@
|
|||
<script>
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import Flash from '~/flash';
|
||||
import modal from '~/vue_shared/components/modal.vue';
|
||||
import { s__ } from '~/locale';
|
||||
import createFlash from '~/flash';
|
||||
import GlModal from '~/vue_shared/components/gl_modal.vue';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import { s__ } from '~/locale';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
modal,
|
||||
GlModal,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
|
@ -17,7 +17,7 @@
|
|||
},
|
||||
computed: {
|
||||
text() {
|
||||
return s__('AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.');
|
||||
return s__('AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -28,7 +28,7 @@
|
|||
redirectTo(response.request.responseURL);
|
||||
})
|
||||
.catch((error) => {
|
||||
Flash(s__('AdminArea|Stopping jobs failed'));
|
||||
createFlash(s__('AdminArea|Stopping jobs failed'));
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
@ -37,11 +37,13 @@
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<modal
|
||||
<gl-modal
|
||||
id="stop-jobs-modal"
|
||||
:title="s__('AdminArea|Stop all jobs?')"
|
||||
:text="text"
|
||||
kind="danger"
|
||||
:primary-button-label="s__('AdminArea|Stop jobs')"
|
||||
@submit="onSubmit" />
|
||||
:header-title-text="s__('AdminArea|Stop all jobs?')"
|
||||
footer-primary-button-variant="danger"
|
||||
:footer-primary-button-text="s__('AdminArea|Stop jobs')"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
{{ text }}
|
||||
</gl-modal>
|
||||
</template>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
106
app/assets/javascripts/vue_shared/components/gl_modal.vue
Normal file
106
app/assets/javascripts/vue_shared/components/gl_modal.vue
Normal file
|
@ -0,0 +1,106 @@
|
|||
<script>
|
||||
const buttonVariants = [
|
||||
'danger',
|
||||
'primary',
|
||||
'success',
|
||||
'warning',
|
||||
];
|
||||
|
||||
export default {
|
||||
name: 'GlModal',
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
headerTitleText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
footerPrimaryButtonVariant: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'primary',
|
||||
validator: value => buttonVariants.indexOf(value) !== -1,
|
||||
},
|
||||
footerPrimaryButtonText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
emitCancel(event) {
|
||||
this.$emit('cancel', event);
|
||||
},
|
||||
emitSubmit(event) {
|
||||
this.$emit('submit', event);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="id"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="modal-dialog"
|
||||
role="document"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<slot name="header">
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
data-dismiss="modal"
|
||||
:aria-label="s__('Modal|Close')"
|
||||
@click="emitCancel($event)"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title">
|
||||
<slot name="title">
|
||||
{{ headerTitleText }}
|
||||
</slot>
|
||||
</h4>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<slot name="footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
data-dismiss="modal"
|
||||
@click="emitCancel($event)"
|
||||
>
|
||||
{{ s__('Modal|Cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="`btn-${footerPrimaryButtonVariant}`"
|
||||
data-dismiss="modal"
|
||||
@click="emitSubmit($event)"
|
||||
>
|
||||
{{ footerPrimaryButtonText }}
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -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 } }
|
||||
|
|
5
changelogs/unreleased/winh-new-modal-component.yml
Normal file
5
changelogs/unreleased/winh-new-modal-component.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add new modal Vue component
|
||||
merge_request: 17108
|
||||
author:
|
||||
type: changed
|
61
doc/development/fe_guide/components.md
Normal file
61
doc/development/fe_guide/components.md
Normal file
|
@ -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
|
||||
<gl-modal
|
||||
id="dogs-out-modal"
|
||||
:header-title-text="s__('ModalExample|Let the dogs out?')"
|
||||
footer-primary-button-variant="danger"
|
||||
:footer-primary-button-text="s__('ModalExample|Let them out')"
|
||||
@submit="letOut(theDogs)"
|
||||
>
|
||||
{{ s__('ModalExample|You’re about to let the dogs out.') }}
|
||||
</gl-modal>
|
||||
```
|
||||
|
||||
![example modal](img/gl-modal.png)
|
|
@ -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).
|
||||
|
|
BIN
doc/development/fe_guide/img/gl-modal.png
Normal file
BIN
doc/development/fe_guide/img/gl-modal.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -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
|
||||
|
|
192
spec/javascripts/vue_shared/components/gl_modal_spec.js
Normal file
192
spec/javascripts/vue_shared/components/gl_modal_spec.js
Normal file
|
@ -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(`
|
||||
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
|
||||
<div id="modal-container"></div>
|
||||
`);
|
||||
|
||||
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 = `
|
||||
<gl-modal>
|
||||
<template slot="${slotName}">${slotContent}</template>
|
||||
</gl-modal>
|
||||
`;
|
||||
} else {
|
||||
template = `<gl-modal>${slotContent}</gl-modal>`;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue