mirror of
https://github.com/frappe/books.git
synced 2024-12-23 11:29:03 +00:00
Modal
- Simplify modal plugin - Support multiple stacked modals in a modal container - Add formModal plugin
This commit is contained in:
parent
4fe78a2878
commit
8f2c48c3df
@ -22,7 +22,7 @@
|
||||
"eslint": "^4.19.1",
|
||||
"express": "^4.16.2",
|
||||
"flatpickr": "^4.3.2",
|
||||
"frappe-datatable": "^1.1.1",
|
||||
"frappe-datatable": "^1.1.2",
|
||||
"jquery": "^3.3.1",
|
||||
"jwt-simple": "^0.5.1",
|
||||
"luxon": "^1.0.0",
|
||||
|
@ -2,10 +2,7 @@
|
||||
<div class="frappe-form">
|
||||
<form-actions
|
||||
v-if="shouldRenderForm"
|
||||
:doctype="doctype"
|
||||
:name="name"
|
||||
:title="formTitle"
|
||||
:isDirty="isDirty"
|
||||
:doc="doc"
|
||||
@save="save"
|
||||
/>
|
||||
<div class="p-3">
|
||||
@ -38,7 +35,6 @@ export default {
|
||||
docLoaded: false,
|
||||
notFound: false,
|
||||
invalid: false,
|
||||
isDirty: false,
|
||||
invalidFields: []
|
||||
}
|
||||
},
|
||||
@ -48,21 +44,12 @@ export default {
|
||||
},
|
||||
shouldRenderForm() {
|
||||
return this.name && this.docLoaded;
|
||||
},
|
||||
formTitle() {
|
||||
if (this.doc._notInserted) {
|
||||
return _('New {0}', _(this.doctype));
|
||||
}
|
||||
return this.doc[this.meta.titleField];
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
if (!this.name) return;
|
||||
try {
|
||||
this.doc = await frappe.getDoc(this.doctype, this.name);
|
||||
this.doc.on('change', () => {
|
||||
this.isDirty = this.doc._dirty;
|
||||
});
|
||||
|
||||
if (this.doc._notInserted && this.meta.fields.map(df => df.fieldname).includes('name')) {
|
||||
// For a user editable name field,
|
||||
|
@ -1,11 +1,38 @@
|
||||
<template>
|
||||
<div class="frappe-form-actions d-flex justify-content-between align-items-center p-3 border-bottom">
|
||||
<h5 class="m-0">{{ title || name }}</h5>
|
||||
<h5 class="m-0">{{ title }}</h5>
|
||||
<f-button primary :disabled="!isDirty" @click="$emit('save')">{{ _('Save') }}</f-button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import frappe from 'frappejs';
|
||||
|
||||
export default {
|
||||
props: ['doctype', 'name', 'title', 'isDirty']
|
||||
props: ['doc'],
|
||||
data() {
|
||||
return {
|
||||
isDirty: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.doc.on('change', () => {
|
||||
this.isDirty = this.doc._dirty;
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
meta() {
|
||||
return frappe.getMeta(this.doc.doctype);
|
||||
},
|
||||
title() {
|
||||
const _ = this._;
|
||||
|
||||
if (this.doc._notInserted) {
|
||||
return _('New {0}', _(this.doc.doctype));
|
||||
}
|
||||
|
||||
const titleField = this.meta.titleField || 'name';
|
||||
return this.doc[titleField];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<div class="col" v-for="(column, j) in section.columns" :key="j">
|
||||
<frappe-control
|
||||
v-for="fieldname in column.fields"
|
||||
v-if="fieldIsNotHidden(fieldname)"
|
||||
v-if="shouldRenderField(fieldname)"
|
||||
:key="fieldname"
|
||||
:docfield="getDocField(fieldname)"
|
||||
:value="$data[fieldname]"
|
||||
@ -18,7 +18,7 @@
|
||||
<div v-if="!layout">
|
||||
<frappe-control
|
||||
v-for="docfield in fields"
|
||||
v-if="!docfield.hidden"
|
||||
v-if="shouldRenderField(docfield.fieldname)"
|
||||
:key="docfield.fieldname"
|
||||
:docfield="docfield"
|
||||
:value="$data[docfield.fieldname]"
|
||||
@ -60,8 +60,18 @@ export default {
|
||||
getDocField(fieldname) {
|
||||
return this.fields.find(df => df.fieldname === fieldname);
|
||||
},
|
||||
fieldIsNotHidden(fieldname) {
|
||||
return !Boolean(this.getDocField(fieldname).hidden);
|
||||
shouldRenderField(fieldname) {
|
||||
const hidden = Boolean(this.getDocField(fieldname).hidden);
|
||||
|
||||
if (hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fieldname === 'name' && !this.doc._notInserted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
updateDoc(fieldname, value) {
|
||||
this.doc.set(fieldname, value);
|
||||
|
@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :class="['modal fade', modalClasses]" :style="{display: show ? 'block' : ''}" id="frappe-modal"
|
||||
tabindex="-1" role="dialog" aria-labelledby="frappe-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="frappe-modal-label">{{ title }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" @click="closeModal">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body modal-height">
|
||||
<component :is="bodyComponent" v-bind="bodyProps"/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<component :is="footerComponent" v-bind="footerProps"/>
|
||||
<f-button secondary @click="closeModal">Close</f-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop show" v-show="show" @click="closeModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
show: Boolean,
|
||||
title: {
|
||||
type: String,
|
||||
default: "Modal Title"
|
||||
},
|
||||
bodyComponent: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
bodyProps: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
footerComponent: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
footerProps: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
modalClasses() {
|
||||
return {
|
||||
show: this.show
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close-modal');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.modal-height {
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
51
ui/components/Modal/Modal.vue
Normal file
51
ui/components/Modal/Modal.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div :class="['modal fade show d-block']"
|
||||
tabindex="-1" role="dialog" aria-labelledby="frappe-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ title }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" @click="closeModal">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body modal-height">
|
||||
<component :is="component" v-bind="props" v-on="events"/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<f-button secondary @click="closeModal">{{ _('Close') }}</f-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: "Modal Title"
|
||||
},
|
||||
component: {
|
||||
type: Object
|
||||
},
|
||||
props: {
|
||||
type: Object
|
||||
},
|
||||
events: {
|
||||
type: Object
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close-modal');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.modal-height {
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
64
ui/components/Modal/ModalContainer.vue
Normal file
64
ui/components/Modal/ModalContainer.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="modal-container">
|
||||
<modal
|
||||
:key="modal.id"
|
||||
v-for="modal in modals"
|
||||
v-bind="modal"
|
||||
@close-modal="onModalClose(modal.id)"
|
||||
></modal>
|
||||
<div class="modal-backdrop show" v-show="modals.length"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Modal from './Modal';
|
||||
import Plugin from './plugin';
|
||||
|
||||
export default {
|
||||
name: 'ModalContainer',
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentId: 0,
|
||||
modals: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
Plugin.modalContainer = this;
|
||||
|
||||
Plugin.event.$on('hide', (id) => {
|
||||
if (!id) {
|
||||
console.warn(`id not provided in $modal.hide method, the last modal in the stack will be hidden`);
|
||||
}
|
||||
this.onModalClose(id);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
add(component, props, events) {
|
||||
this.currentId++;
|
||||
this.modals.push({
|
||||
id: this.currentId,
|
||||
component,
|
||||
props,
|
||||
events
|
||||
});
|
||||
return this.currentId;
|
||||
},
|
||||
removeModal(id) {
|
||||
if (!id) {
|
||||
id = this.currentId;
|
||||
}
|
||||
this.currentId--;
|
||||
this.modals = this.modals.filter(modal => modal.id !== id);
|
||||
},
|
||||
onModalClose(id) {
|
||||
if (id) {
|
||||
const modal = this.modals.find(modal => modal.id === id);
|
||||
modal.props.onClose && modal.props.onClose();
|
||||
}
|
||||
this.removeModal(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
25
ui/components/Modal/plugin.js
Normal file
25
ui/components/Modal/plugin.js
Normal file
@ -0,0 +1,25 @@
|
||||
import ModalContainer from './ModalContainer';
|
||||
|
||||
const Plugin = {
|
||||
install (Vue) {
|
||||
|
||||
this.event = new Vue();
|
||||
|
||||
Vue.prototype.$modal = {
|
||||
show(...args) {
|
||||
Plugin.modalContainer.add(...args);
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
Plugin.event.$emit('hide', id);
|
||||
}
|
||||
}
|
||||
|
||||
// create modal container
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
new Vue({ render: h => h(ModalContainer) }).$mount(div);
|
||||
}
|
||||
}
|
||||
|
||||
export default Plugin;
|
@ -8,134 +8,145 @@ import Form from '../Form/Form';
|
||||
import { _ } from 'frappejs/utils';
|
||||
|
||||
export default {
|
||||
extends: Autocomplete,
|
||||
watch: {
|
||||
value(newValue) {
|
||||
this.$refs.input.value = newValue;
|
||||
}
|
||||
extends: Autocomplete,
|
||||
watch: {
|
||||
value(newValue) {
|
||||
this.$refs.input.value = newValue;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getList(query) {
|
||||
const list = await frappe.db.getAll({
|
||||
doctype: this.getTarget(),
|
||||
filters: {
|
||||
keywords: ['like', query]
|
||||
},
|
||||
fields: ['name'],
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const plusIcon = feather.icons.plus.toSvg({
|
||||
class: 'm-1',
|
||||
width: 16,
|
||||
height: 16
|
||||
});
|
||||
|
||||
return list
|
||||
.map(d => ({
|
||||
label: d.name,
|
||||
value: d.name
|
||||
}))
|
||||
.concat({
|
||||
label: plusIcon + ' New ' + this.getTarget(),
|
||||
value: '__newItem'
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async getList(query) {
|
||||
const list = await frappe.db.getAll({
|
||||
doctype: this.getTarget(),
|
||||
filters: {
|
||||
keywords: ["like", query]
|
||||
},
|
||||
fields: ['name'],
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const plusIcon = feather.icons.plus.toSvg({
|
||||
class: 'm-1',
|
||||
width: 16,
|
||||
height: 16
|
||||
});
|
||||
|
||||
return list
|
||||
.map(d => ({
|
||||
label: d.name,
|
||||
value: d.name
|
||||
}))
|
||||
.concat({
|
||||
label: plusIcon + ' New ' + this.getTarget(),
|
||||
value: '__newItem'
|
||||
})
|
||||
},
|
||||
getWrapperElement(h) {
|
||||
return h('div', {
|
||||
class: ['form-group', ...this.wrapperClass],
|
||||
attrs: {
|
||||
'data-fieldname': this.docfield.fieldname
|
||||
}
|
||||
}, [
|
||||
this.getLabelElement(h),
|
||||
this.getInputElement(h),
|
||||
this.getFollowLink(h)
|
||||
]);
|
||||
},
|
||||
getFollowLink(h) {
|
||||
const doctype = this.getTarget();
|
||||
const name = this.value;
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
getWrapperElement(h) {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: ['form-group', ...this.wrapperClass],
|
||||
attrs: {
|
||||
'data-fieldname': this.docfield.fieldname
|
||||
}
|
||||
},
|
||||
[
|
||||
this.getLabelElement(h),
|
||||
this.getInputElement(h),
|
||||
this.getFollowLink(h)
|
||||
]
|
||||
);
|
||||
},
|
||||
getFollowLink(h) {
|
||||
const doctype = this.getTarget();
|
||||
const name = this.value;
|
||||
|
||||
return h(FeatherIcon, {
|
||||
props: {
|
||||
name: 'arrow-right-circle'
|
||||
},
|
||||
class: ['text-muted'],
|
||||
style: {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
bottom: '4px',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
nativeOn: {
|
||||
click: () => {
|
||||
this.$router.push(`/edit/${doctype}/${name}`);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return h(FeatherIcon, {
|
||||
props: {
|
||||
name: 'arrow-right-circle'
|
||||
},
|
||||
class: ['text-muted'],
|
||||
style: {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
bottom: '4px',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
nativeOn: {
|
||||
click: () => {
|
||||
this.$router.push(`/edit/${doctype}/${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
getTarget() {
|
||||
return this.docfield.target;
|
||||
},
|
||||
sort() {
|
||||
return (a, b) => {
|
||||
if (a.value === '__newItem') {
|
||||
return 1;
|
||||
}
|
||||
if (b.value === '___newItem') {
|
||||
return -1;
|
||||
}
|
||||
if (a.value === b.value) {
|
||||
return 0;
|
||||
}
|
||||
if (a.value < b.value) {
|
||||
return -1;
|
||||
}
|
||||
if (a.value > b.value) {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
},
|
||||
filter() {
|
||||
return (suggestion, txt) => {
|
||||
if (suggestion.value === '__newItem') {
|
||||
return true;
|
||||
}
|
||||
return Awesomplete.FILTER_CONTAINS(suggestion, txt);
|
||||
};
|
||||
},
|
||||
bindEvents() {
|
||||
const input = this.$refs.input;
|
||||
|
||||
input.addEventListener('awesomplete-select', async e => {
|
||||
// new item action
|
||||
if (e.text && e.text.value === '__newItem') {
|
||||
e.preventDefault();
|
||||
const newDoc = await frappe.getNewDoc(this.getTarget());
|
||||
|
||||
this.$formModal.open(
|
||||
newDoc,
|
||||
{
|
||||
defaultValues: {
|
||||
name: input.value !== '__newItem' ? input.value : null
|
||||
},
|
||||
onClose: () => {
|
||||
// if new doc was not created
|
||||
// then reset the input value
|
||||
if (this.value === '__newItem') {
|
||||
this.handleChange('');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
newDoc.on('afterInsert', data => {
|
||||
// if new doc was created
|
||||
// then set the name of the doc in input
|
||||
this.handleChange(newDoc.name);
|
||||
this.$formModal.close();
|
||||
});
|
||||
},
|
||||
getTarget() {
|
||||
return this.docfield.target;
|
||||
},
|
||||
sort() {
|
||||
return (a, b) => {
|
||||
if (a.value === '__newitem' || b.value === '__newitem') {
|
||||
return -1;
|
||||
}
|
||||
return a.value > b.value;
|
||||
}
|
||||
},
|
||||
filter() {
|
||||
return (suggestion, txt) => {
|
||||
if (suggestion.value === '__newItem') {
|
||||
return true;
|
||||
}
|
||||
return Awesomplete.FILTER_CONTAINS(suggestion, txt);
|
||||
}
|
||||
},
|
||||
bindEvents() {
|
||||
const input = this.$refs.input;
|
||||
|
||||
input.addEventListener('awesomplete-select', async (e) => {
|
||||
// new item action
|
||||
if (e.text && e.text.value === '__newItem') {
|
||||
e.preventDefault();
|
||||
const newDoc = await frappe.getNewDoc(this.getTarget());
|
||||
|
||||
this.$modal.show({
|
||||
title: _('New {0}', _(newDoc.doctype)),
|
||||
bodyComponent: Form,
|
||||
bodyProps: {
|
||||
doctype: newDoc.doctype,
|
||||
name: newDoc.name,
|
||||
defaultValues: {
|
||||
name: input.value
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
newDoc.on('afterInsert', (data) => {
|
||||
// if new doc was created
|
||||
// then set the name of the doc in input
|
||||
this.handleChange(newDoc.name);
|
||||
this.$modal.hide();
|
||||
});
|
||||
|
||||
this.$modal.observable().once('modal.hide', () => {
|
||||
// if new doc was not created
|
||||
// then reset the input value
|
||||
if (this.value === '__newItem') {
|
||||
this.handleChange('');
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
27
ui/plugins/formModal.js
Normal file
27
ui/plugins/formModal.js
Normal file
@ -0,0 +1,27 @@
|
||||
import Form from '../components/Form/Form';
|
||||
|
||||
export default function installFormModal(Vue) {
|
||||
|
||||
Vue.mixin({
|
||||
computed: {
|
||||
$formModal() {
|
||||
const open = (doc, options = {}) => {
|
||||
const { defaultValues = null, onClose = null } = options;
|
||||
this.$modal.show(Form, {
|
||||
doctype: doc.doctype,
|
||||
name: doc.name,
|
||||
defaultValues,
|
||||
onClose
|
||||
});
|
||||
}
|
||||
|
||||
const close = () => this.$modal.hide();
|
||||
|
||||
return {
|
||||
open,
|
||||
close
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import Observable from 'frappejs/utils/observable';
|
||||
|
||||
const Bus = new Observable();
|
||||
|
||||
export default {
|
||||
// enable use of this.$modal in every component
|
||||
// this also keeps only one modal in the DOM at any time
|
||||
// which is the recommended way by bootstrap
|
||||
install (Vue) {
|
||||
Vue.prototype.$modal = {
|
||||
show(options) {
|
||||
Bus.trigger('showModal', options);
|
||||
},
|
||||
|
||||
hide() {
|
||||
Bus.trigger('hideModal');
|
||||
},
|
||||
|
||||
observable() {
|
||||
return Bus;
|
||||
}
|
||||
}
|
||||
|
||||
Vue.mixin({
|
||||
data() {
|
||||
return {
|
||||
registered: false,
|
||||
modalVisible: false,
|
||||
modalOptions: {},
|
||||
modalListeners: {}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
modalVisible(value) {
|
||||
if (value === true) {
|
||||
Bus.trigger('modal.show');
|
||||
} else {
|
||||
Bus.trigger('modal.hide');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
if (this.registered) return;
|
||||
|
||||
Bus.on('showModal', (options = {}) => {
|
||||
this.modalVisible = true;
|
||||
this.modalOptions = options;
|
||||
});
|
||||
|
||||
Bus.on('hideModal', () => {
|
||||
this.modalVisible = false;
|
||||
this.modalOptions = {};
|
||||
});
|
||||
|
||||
this.registered = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user