mirror of
https://github.com/frappe/books.git
synced 2024-11-10 15:50:56 +00:00
Move vue components to frappejs
This commit is contained in:
parent
8203bfc84e
commit
1d9b615d67
9
ui/components/Button.vue
Normal file
9
ui/components/Button.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template functional>
|
||||||
|
<button type="button"
|
||||||
|
:class="['btn btn-sm', 'btn-' + Object.keys(props).find(key => ['primary', 'secondary', 'light', 'dark'].includes(key))]"
|
||||||
|
v-bind="data.attrs"
|
||||||
|
v-on="listeners"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
25
ui/components/Desk.vue
Normal file
25
ui/components/Desk.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-desk row no-gutters">
|
||||||
|
<frappe-sidebar :sidebarConfig="sidebarConfig"></frappe-sidebar>
|
||||||
|
<frappe-main>
|
||||||
|
<frappe-navbar></frappe-navbar>
|
||||||
|
<slot></slot>
|
||||||
|
</frappe-main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import Main from './Main';
|
||||||
|
import Navbar from './Navbar';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['sidebarConfig'],
|
||||||
|
components: {
|
||||||
|
FrappeSidebar: Sidebar,
|
||||||
|
FrappeMain: Main,
|
||||||
|
FrappeNavbar: Navbar
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
</style>
|
42
ui/components/FeatherIcon.vue
Normal file
42
ui/components/FeatherIcon.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feather-icon" v-html="iconSVG"></div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
|
||||||
|
const validIcons = Object.keys(feather.icons);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator(value) {
|
||||||
|
const valid = validIcons.includes(value);
|
||||||
|
if (!valid) {
|
||||||
|
console.warn(`name property for feather-icon must be one of `, validIcons);
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
iconSVG() {
|
||||||
|
return feather.icons[this.name].toSvg({
|
||||||
|
width: this.size,
|
||||||
|
height: this.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.feather-icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
108
ui/components/Form/Form.vue
Normal file
108
ui/components/Form/Form.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-form">
|
||||||
|
<form-actions
|
||||||
|
v-if="shouldRenderForm"
|
||||||
|
:doctype="doctype"
|
||||||
|
:name="name"
|
||||||
|
:title="formTitle"
|
||||||
|
:isDirty="isDirty"
|
||||||
|
@save="save"
|
||||||
|
/>
|
||||||
|
<div class="p-3">
|
||||||
|
<form-layout
|
||||||
|
v-if="shouldRenderForm"
|
||||||
|
:doc="doc"
|
||||||
|
:fields="meta.fields"
|
||||||
|
:layout="meta.layout"
|
||||||
|
:invalid="invalid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<not-found v-if="notFound" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import frappe from 'frappejs';
|
||||||
|
import FormLayout from './FormLayout';
|
||||||
|
import FormActions from './FormActions';
|
||||||
|
import { _ } from 'frappejs/utils';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Form',
|
||||||
|
props: ['doctype', 'name'],
|
||||||
|
components: {
|
||||||
|
FormActions,
|
||||||
|
FormLayout
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
docLoaded: false,
|
||||||
|
notFound: false,
|
||||||
|
invalid: false,
|
||||||
|
isDirty: false,
|
||||||
|
invalidFields: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
meta() {
|
||||||
|
return frappe.getMeta(this.doctype);
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
this.docLoaded = true;
|
||||||
|
} catch(e) {
|
||||||
|
this.notFound = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async save() {
|
||||||
|
this.setValidity();
|
||||||
|
if (this.invalid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.doc._notInserted) {
|
||||||
|
await this.doc.insert();
|
||||||
|
} else {
|
||||||
|
await this.doc.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('save', this.doc);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onValidate(fieldname, isValid) {
|
||||||
|
if (!isValid && !this.invalidFields.includes(fieldname)) {
|
||||||
|
this.invalidFields.push(fieldname);
|
||||||
|
} else if (isValid) {
|
||||||
|
this.invalidFields = this.invalidFields.filter(invalidField => invalidField !== fieldname)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setValidity() {
|
||||||
|
const form = this.$el.querySelector('form');
|
||||||
|
let validity = form.checkValidity();
|
||||||
|
this.invalid = !validity;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
</style>
|
11
ui/components/Form/FormActions.vue
Normal file
11
ui/components/Form/FormActions.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
|
<f-button primary :disabled="!isDirty" @click="$emit('save')">{{ _('Save') }}</f-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['doctype', 'name', 'title', 'isDirty']
|
||||||
|
}
|
||||||
|
</script>
|
87
ui/components/Form/FormLayout.vue
Normal file
87
ui/components/Form/FormLayout.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<form :class="['frappe-form-layout', { 'was-validated': invalid }]">
|
||||||
|
<div class="row" v-if="layoutConfig"
|
||||||
|
v-for="(section, i) in layoutConfig.sections" :key="i"
|
||||||
|
v-show="showSection(i)"
|
||||||
|
>
|
||||||
|
<div class="col" v-for="(column, j) in section.columns" :key="j">
|
||||||
|
<frappe-control
|
||||||
|
v-for="fieldname in column.fields"
|
||||||
|
:key="fieldname"
|
||||||
|
:docfield="getDocField(fieldname)"
|
||||||
|
:value="$data[fieldname]"
|
||||||
|
@change="value => updateDoc(fieldname, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!layout">
|
||||||
|
<frappe-control
|
||||||
|
v-for="docfield in fields"
|
||||||
|
:key="docfield.fieldname"
|
||||||
|
:docfield="docfield"
|
||||||
|
:value="$data[docfield.fieldname]"
|
||||||
|
@change="value => updateDoc(docfield.fieldname, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FormLayout',
|
||||||
|
props: ['doc', 'fields', 'layout', 'invalid', 'currentSection'],
|
||||||
|
data() {
|
||||||
|
const dataObj = {};
|
||||||
|
for (let df of this.fields) {
|
||||||
|
dataObj[df.fieldname] = this.doc[df.fieldname];
|
||||||
|
|
||||||
|
if (df.fieldtype === 'Table' && !dataObj[df.fieldname]) {
|
||||||
|
dataObj[df.fieldname] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataObj;
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.doc.on('change', ({ doc, fieldname }) => {
|
||||||
|
if (fieldname) {
|
||||||
|
// update value
|
||||||
|
this[fieldname] = doc[fieldname];
|
||||||
|
} else {
|
||||||
|
// update all values
|
||||||
|
this.fields.forEach(df => {
|
||||||
|
this[df.fieldname] = doc[df.fieldname];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getDocField(fieldname) {
|
||||||
|
return this.fields.find(df => df.fieldname === fieldname);
|
||||||
|
},
|
||||||
|
updateDoc(fieldname, value) {
|
||||||
|
this.doc.set(fieldname, value);
|
||||||
|
},
|
||||||
|
showSection(i) {
|
||||||
|
if (this.layoutConfig.paginated) {
|
||||||
|
return this.currentSection === i;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
layoutConfig() {
|
||||||
|
if (!this.layout) return false;
|
||||||
|
|
||||||
|
let config = this.layout;
|
||||||
|
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
config = {
|
||||||
|
sections: config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
24
ui/components/Indicator.vue
Normal file
24
ui/components/Indicator.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="['indicator', 'indicator-' + color]"></span>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['color']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import "../styles/variables";
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: $gray-400;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-blue {
|
||||||
|
background-color: $primary;
|
||||||
|
}
|
||||||
|
</style>
|
104
ui/components/List/List.vue
Normal file
104
ui/components/List/List.vue
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-list">
|
||||||
|
<list-actions
|
||||||
|
:doctype="doctype"
|
||||||
|
:showDelete="checkList.length"
|
||||||
|
@new="newDoc"
|
||||||
|
@delete="deleteCheckedItems"
|
||||||
|
/>
|
||||||
|
<ul class="list-group">
|
||||||
|
<list-item v-for="doc of data" :key="doc.name"
|
||||||
|
:id="doc.name"
|
||||||
|
:isActive="doc.name === $route.params.name"
|
||||||
|
:isChecked="isChecked(doc.name)"
|
||||||
|
@clickItem="openForm(doc.name)"
|
||||||
|
@checkItem="toggleCheck(doc.name)">
|
||||||
|
{{ doc[meta.titleField || 'name'] }}
|
||||||
|
</list-item>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import frappe from 'frappejs';
|
||||||
|
import ListActions from './ListActions';
|
||||||
|
import ListItem from './ListItem';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'List',
|
||||||
|
props: ['doctype', 'filters'],
|
||||||
|
components: {
|
||||||
|
ListActions,
|
||||||
|
ListItem
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
checkList: [],
|
||||||
|
activeItem: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
meta() {
|
||||||
|
return frappe.getMeta(this.doctype);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
frappe.db.on(`change:${this.doctype}`, () => {
|
||||||
|
this.updateList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateList();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async newDoc() {
|
||||||
|
let doc = await frappe.getNewDoc(this.doctype);
|
||||||
|
this.$router.push(`/edit/${this.doctype}/${doc.name}`);
|
||||||
|
},
|
||||||
|
async updateList() {
|
||||||
|
const data = await frappe.db.getAll({
|
||||||
|
doctype: this.doctype,
|
||||||
|
fields: ['name', ...this.meta.keywordFields, this.meta.titleField],
|
||||||
|
filters: this.filters || null
|
||||||
|
});
|
||||||
|
|
||||||
|
this.data = data;
|
||||||
|
},
|
||||||
|
openForm(name) {
|
||||||
|
this.activeItem = name;
|
||||||
|
this.$router.push(`/edit/${this.doctype}/${name}`);
|
||||||
|
},
|
||||||
|
async deleteCheckedItems() {
|
||||||
|
await frappe.db.deleteMany(this.doctype, this.checkList);
|
||||||
|
this.checkList = [];
|
||||||
|
},
|
||||||
|
toggleCheck(name) {
|
||||||
|
if (this.checkList.includes(name)) {
|
||||||
|
this.checkList = this.checkList.filter(docname => docname !== name);
|
||||||
|
} else {
|
||||||
|
this.checkList = this.checkList.concat(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isChecked(name) {
|
||||||
|
return this.checkList.includes(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../styles/variables";
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item:not(.active):hover {
|
||||||
|
background-color: $light;
|
||||||
|
}
|
||||||
|
</style>
|
12
ui/components/List/ListActions.vue
Normal file
12
ui/components/List/ListActions.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-list-actions d-flex justify-content-between align-items-center p-3 border-bottom">
|
||||||
|
<h5 class="m-0">{{ doctype }} List</h5>
|
||||||
|
<button v-if="showDelete" class="btn btn-danger btn-sm" @click="$emit('delete')">Delete</button>
|
||||||
|
<button v-else class="btn btn-primary btn-sm" @click="$emit('new')">New</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['doctype', 'showDelete']
|
||||||
|
}
|
||||||
|
</script>
|
21
ui/components/List/ListItem.vue
Normal file
21
ui/components/List/ListItem.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="classList" @click.self="$emit('clickItem')">
|
||||||
|
<div class="custom-control custom-checkbox d-flex">
|
||||||
|
<input type="checkbox" class="custom-control-input" :id="id"
|
||||||
|
:value="isChecked" @change="$emit('checkItem', isChecked)"
|
||||||
|
>
|
||||||
|
<label class="custom-control-label" :for="id"></label>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['id', 'isActive', 'isChecked'],
|
||||||
|
computed: {
|
||||||
|
classList() {
|
||||||
|
return ['list-group-item d-flex align-items-center', this.isActive ? 'bg-light' : ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
5
ui/components/Main.vue
Normal file
5
ui/components/Main.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<main class="frappe-main col-10">
|
||||||
|
<slot></slot>
|
||||||
|
</main>
|
||||||
|
</template>
|
70
ui/components/Modal.vue
Normal file
70
ui/components/Modal.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<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>
|
138
ui/components/ModelTable.vue
Normal file
138
ui/components/ModelTable.vue
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div ref="wrapper" class="datatable-wrapper"></div>
|
||||||
|
<div class="table-actions mt-1">
|
||||||
|
<button type="button" @click="addRow" class="btn btn-sm btn-light border">Add Row</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue';
|
||||||
|
import frappe from 'frappejs';
|
||||||
|
import Observable from 'frappejs/utils/observable';
|
||||||
|
import DataTable from 'frappe-datatable';
|
||||||
|
import FrappeControl from './controls/FrappeControl';
|
||||||
|
import { convertFieldsToDatatableColumns } from 'frappejs/client/ui/utils';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['doctype', 'rows'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
docs: this.getRowDocs()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
meta() {
|
||||||
|
return frappe.getMeta(this.doctype);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.datatable = new DataTable(this.$refs.wrapper, {
|
||||||
|
columns: this.getColumns(),
|
||||||
|
data: this.docs,
|
||||||
|
layout: 'fluid',
|
||||||
|
checkboxColumn: true,
|
||||||
|
checkedRowStatus: false,
|
||||||
|
getEditor: (colIndex, rowIndex, value, parent) => {
|
||||||
|
|
||||||
|
let inputComponent = null;
|
||||||
|
const docfield = this.datatable.getColumn(colIndex).field;
|
||||||
|
|
||||||
|
const fieldWrapper = document.createElement('div');
|
||||||
|
parent.appendChild(fieldWrapper);
|
||||||
|
|
||||||
|
const updateData = (fieldname, value) => {
|
||||||
|
const docs = this.datatable.datamanager.data;
|
||||||
|
const doc = docs[rowIndex];
|
||||||
|
doc.set(fieldname, value);
|
||||||
|
this.emitChange(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initValue() {
|
||||||
|
inputComponent = new Vue({
|
||||||
|
el: fieldWrapper,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
docfield,
|
||||||
|
value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
FrappeControl
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$el.focus();
|
||||||
|
},
|
||||||
|
template: `<frappe-control
|
||||||
|
:docfield="docfield"
|
||||||
|
:value="value"
|
||||||
|
@change="value => updateValue(docfield.fieldname, value)"
|
||||||
|
:onlyInput="true"
|
||||||
|
/>`,
|
||||||
|
methods: {
|
||||||
|
updateValue(fieldname, value) {
|
||||||
|
this.value = value;
|
||||||
|
updateData(fieldname, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setValue: (value, rowIndex, column) => {
|
||||||
|
inputComponent.value = value;
|
||||||
|
},
|
||||||
|
getValue: () => {
|
||||||
|
return inputComponent.$el.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
this.datatable.destroy();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
docs: function(newVal, oldVal) {
|
||||||
|
this.datatable.refresh(newVal);
|
||||||
|
},
|
||||||
|
rows: function(newVal, oldVal) {
|
||||||
|
this.docs = this.getRowDocs();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getRowDocs() {
|
||||||
|
return (this.rows || []).map((row, i) => {
|
||||||
|
const doc = new Observable();
|
||||||
|
doc.set('idx', i);
|
||||||
|
for (let fieldname in row) {
|
||||||
|
doc.set(fieldname, row[fieldname]);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getColumns() {
|
||||||
|
return convertFieldsToDatatableColumns(this.meta.fields);
|
||||||
|
},
|
||||||
|
addRow() {
|
||||||
|
const doc = new Observable();
|
||||||
|
doc.set('idx', this.docs.length);
|
||||||
|
this.docs.push(doc);
|
||||||
|
},
|
||||||
|
emitChange(doc) {
|
||||||
|
this.$emit('update:rows', this.docs, doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
@import "frappe-datatable/dist/frappe-datatable.css";
|
||||||
|
|
||||||
|
.datatable-wrapper {
|
||||||
|
.form-control {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
7
ui/components/Navbar.vue
Normal file
7
ui/components/Navbar.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="frappe-navbar navbar navbar-light bg-light row no-gutters border-bottom border-top">
|
||||||
|
<form class="form-inline col-4 pr-3">
|
||||||
|
<input type="search" name="search" class="form-control shadow-none w-100" placeholder="Search...">
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
</template>
|
11
ui/components/NotFound.vue
Normal file
11
ui/components/NotFound.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-not-found d-flex flex-column align-items-center justify-content-center">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Not Found</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
.frappe-not-found {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
47
ui/components/Sidebar.vue
Normal file
47
ui/components/Sidebar.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-sidebar col-2 bg-light border-right">
|
||||||
|
<div class="navbar border-bottom">
|
||||||
|
<div class="navbar-text">
|
||||||
|
TennisMart
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-3" v-for="(sidebarGroup, index) in sidebarConfig" :key="index">
|
||||||
|
<h6 v-if="sidebarGroup.title" class="sidebar-heading nav-link text-muted text-uppercase m-0">
|
||||||
|
{{ sidebarGroup.title }}
|
||||||
|
</h6>
|
||||||
|
<nav class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a v-for="item in sidebarGroup.items" :key="item.route"
|
||||||
|
:href="item.route"
|
||||||
|
:class="['nav-link', isActive(item) ? 'text-light bg-secondary' : 'text-dark']" >
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['sidebarConfig'],
|
||||||
|
methods: {
|
||||||
|
isActive(item) {
|
||||||
|
if (this.$route.params.doctype) {
|
||||||
|
return this.$route.params.doctype === item.label;
|
||||||
|
}
|
||||||
|
const route = item.route.slice(1);
|
||||||
|
return this.$route.path === route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frappe-sidebar {
|
||||||
|
min-height: calc(100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-heading {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
84
ui/components/controls/Autocomplete.vue
Normal file
84
ui/components/controls/Autocomplete.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script>
|
||||||
|
import Awesomplete from 'awesomplete';
|
||||||
|
import Data from './Data';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Data,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
awesomplete: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setupAwesomplete();
|
||||||
|
this.awesomplete.container.classList.add('form-control');
|
||||||
|
this.awesomplete.ul.classList.add('dropdown-menu');
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getInputListeners() {
|
||||||
|
return {
|
||||||
|
input: async e => {
|
||||||
|
this.awesomplete.list = await this.getList(e.target.value);
|
||||||
|
},
|
||||||
|
'awesomplete-select': e => {
|
||||||
|
const value = e.text.value;
|
||||||
|
this.handleChange(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getList(text) {
|
||||||
|
return this.docfield.getList(text);
|
||||||
|
},
|
||||||
|
setupAwesomplete() {
|
||||||
|
const input = this.$refs.input;
|
||||||
|
this.awesomplete = new Awesomplete(input, {
|
||||||
|
minChars: 0,
|
||||||
|
maxItems: 99,
|
||||||
|
sort: this.sort(),
|
||||||
|
filter: this.filter(),
|
||||||
|
item: (text, input) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.classList.add('dropdown-item');
|
||||||
|
li.classList.add('d-flex');
|
||||||
|
li.classList.add('align-items-center');
|
||||||
|
li.innerHTML = text.label;
|
||||||
|
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
},
|
||||||
|
bindEvents() {
|
||||||
|
|
||||||
|
},
|
||||||
|
sort() {
|
||||||
|
// return a function that handles sorting of items
|
||||||
|
},
|
||||||
|
filter() {
|
||||||
|
// return a function that filters list suggestions based on input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
@import "../../styles/variables";
|
||||||
|
@import "~awesomplete/awesomplete.base";
|
||||||
|
|
||||||
|
.awesomplete {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
& > ul {
|
||||||
|
padding: $dropdown-padding-y 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu:not([hidden]) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item[aria-selected="true"] {
|
||||||
|
background-color: $dropdown-link-hover-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
103
ui/components/controls/Base.vue
Normal file
103
ui/components/controls/Base.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
render(h) {
|
||||||
|
if (this.onlyInput) {
|
||||||
|
return this.getInputElement(h);
|
||||||
|
}
|
||||||
|
return this.getWrapperElement(h);
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
docfield: Object,
|
||||||
|
value: [String, Number, Array],
|
||||||
|
onlyInput: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
id() {
|
||||||
|
return this.docfield.fieldname + '-'
|
||||||
|
+ document.querySelectorAll(`[data-fieldname="${this.docfield.fieldname}"]`).length;
|
||||||
|
},
|
||||||
|
inputClass() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
wrapperClass() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
labelClass() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getWrapperElement(h) {
|
||||||
|
return h('div', {
|
||||||
|
class: ['form-group', ...this.wrapperClass],
|
||||||
|
attrs: {
|
||||||
|
'data-fieldname': this.docfield.fieldname
|
||||||
|
}
|
||||||
|
}, [this.getLabelElement(h), this.getInputElement(h)]);
|
||||||
|
},
|
||||||
|
getLabelElement(h) {
|
||||||
|
return h('label', {
|
||||||
|
class: [this.labelClass, 'text-muted'],
|
||||||
|
attrs: {
|
||||||
|
for: this.id
|
||||||
|
},
|
||||||
|
domProps: {
|
||||||
|
textContent: this.docfield.label
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getInputElement(h) {
|
||||||
|
return h(this.getInputTag(), {
|
||||||
|
class: this.getInputClass(),
|
||||||
|
attrs: this.getInputAttrs(),
|
||||||
|
on: this.getInputListeners(),
|
||||||
|
domProps: this.getDomProps(),
|
||||||
|
ref: 'input'
|
||||||
|
}, this.getInputChildren(h));
|
||||||
|
},
|
||||||
|
getInputTag() {
|
||||||
|
return 'input';
|
||||||
|
},
|
||||||
|
getInputClass() {
|
||||||
|
return ['form-control', ...this.inputClass];
|
||||||
|
},
|
||||||
|
getInputAttrs() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '',
|
||||||
|
value: this.value,
|
||||||
|
required: this.docfield.required
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInputListeners() {
|
||||||
|
return {
|
||||||
|
change: (e) => {
|
||||||
|
this.handleChange(e.target.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getInputChildren() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getDomProps() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async handleChange(value) {
|
||||||
|
value = this.parse(value);
|
||||||
|
const isValid = await this.validate(value);
|
||||||
|
this.$refs.input.setCustomValidity(isValid === false ? 'error' : '');
|
||||||
|
this.$emit('change', value);
|
||||||
|
},
|
||||||
|
validate() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
parse(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
25
ui/components/controls/Check.vue
Normal file
25
ui/components/controls/Check.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="custom-control custom-checkbox">
|
||||||
|
<input class="custom-control-input" type="checkbox" :id="id" v-model="checkboxValue" @change="emitChange">
|
||||||
|
<label class="custom-control-label" :for="id">{{ docfield.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
checkboxValue: Boolean(this.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange(e) {
|
||||||
|
this.$emit('change', Number(this.checkboxValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
11
ui/components/controls/Code.vue
Normal file
11
ui/components/controls/Code.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script>
|
||||||
|
import Text from './Text';
|
||||||
|
export default {
|
||||||
|
extends: Text,
|
||||||
|
computed: {
|
||||||
|
inputClass() {
|
||||||
|
return ['text-monospace'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
6
ui/components/controls/Currency.vue
Normal file
6
ui/components/controls/Currency.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script>
|
||||||
|
import Float from './Float';
|
||||||
|
export default {
|
||||||
|
extends: Float
|
||||||
|
}
|
||||||
|
</script>
|
8
ui/components/controls/Data.vue
Normal file
8
ui/components/controls/Data.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Data',
|
||||||
|
extends: Base
|
||||||
|
}
|
||||||
|
</script>
|
36
ui/components/controls/Date.vue
Normal file
36
ui/components/controls/Date.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group" v-if="!onlyInput">
|
||||||
|
<label>{{ docfield.label }}</label>
|
||||||
|
<flat-pickr
|
||||||
|
:value="value"
|
||||||
|
class="form-control"
|
||||||
|
@on-change="emitChange">
|
||||||
|
</flat-pickr>
|
||||||
|
</div>
|
||||||
|
<flat-pickr
|
||||||
|
v-else
|
||||||
|
:value="value"
|
||||||
|
class="form-control"
|
||||||
|
@on-change="emitChange"
|
||||||
|
>
|
||||||
|
</flat-pickr>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import flatPickr from 'vue-flatpickr-component';
|
||||||
|
import Data from './Data';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Data,
|
||||||
|
components: {
|
||||||
|
flatPickr
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange(dates, dateString) {
|
||||||
|
this.$emit('change', dateString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
@import "flatpickr/dist/flatpickr.css";
|
||||||
|
</style>
|
14
ui/components/controls/DynamicLink.vue
Normal file
14
ui/components/controls/DynamicLink.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script>
|
||||||
|
import frappe from 'frappejs';
|
||||||
|
import Link from './Link';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Link,
|
||||||
|
inject: ['dynamicLinkTarget'],
|
||||||
|
methods: {
|
||||||
|
getTarget() {
|
||||||
|
return this.dynamicLinkTarget(this.docfield.references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
46
ui/components/controls/File.vue
Normal file
46
ui/components/controls/File.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
computed: {
|
||||||
|
inputClass() {
|
||||||
|
return ['d-none'];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getWrapperElement(h) {
|
||||||
|
let fileName = 'Choose a file..';
|
||||||
|
|
||||||
|
if (this.$refs.input && this.$refs.input.files.length) {
|
||||||
|
fileName = this.$refs.input.files[0].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileButton = h('button', {
|
||||||
|
class: ['btn btn-outline-secondary btn-block'],
|
||||||
|
domProps: {
|
||||||
|
textContent: fileName
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
click: () => this.$refs.input.click()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
class: ['form-group', ...this.wrapperClass],
|
||||||
|
attrs: {
|
||||||
|
'data-fieldname': this.docfield.fieldname
|
||||||
|
}
|
||||||
|
}, [this.getLabelElement(h), this.getInputElement(h), fileButton]);
|
||||||
|
},
|
||||||
|
getInputAttrs() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: 'file',
|
||||||
|
value: this.value,
|
||||||
|
required: this.docfield.required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
30
ui/components/controls/Float.vue
Normal file
30
ui/components/controls/Float.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
computed: {
|
||||||
|
inputClass() {
|
||||||
|
return ['text-right']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getInputListeners() {
|
||||||
|
return {
|
||||||
|
change: e => {
|
||||||
|
this.handleChange(e.target.value);
|
||||||
|
},
|
||||||
|
focus: e => {
|
||||||
|
setTimeout(() => this.$refs.input.select(), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validate(value) {
|
||||||
|
return !isNaN(value);
|
||||||
|
},
|
||||||
|
parse(value) {
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
50
ui/components/controls/FrappeControl.vue
Normal file
50
ui/components/controls/FrappeControl.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="component"
|
||||||
|
:docfield="docfield"
|
||||||
|
:value="value"
|
||||||
|
:onlyInput="onlyInput"
|
||||||
|
@change="$emit('change', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import Autocomplete from './Autocomplete';
|
||||||
|
import Check from './Check';
|
||||||
|
import Code from './Code';
|
||||||
|
import Currency from './Currency';
|
||||||
|
import Data from './Data';
|
||||||
|
import Date from './Date';
|
||||||
|
import DynamicLink from './DynamicLink';
|
||||||
|
import File from './File';
|
||||||
|
import Float from './Float';
|
||||||
|
import Int from './Int';
|
||||||
|
import Link from './Link';
|
||||||
|
import Password from './Password';
|
||||||
|
import Select from './Select';
|
||||||
|
import Table from './Table';
|
||||||
|
import Text from './Text';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['docfield', 'value', 'onlyInput'],
|
||||||
|
computed: {
|
||||||
|
component() {
|
||||||
|
return {
|
||||||
|
Autocomplete,
|
||||||
|
Check,
|
||||||
|
Code,
|
||||||
|
Currency,
|
||||||
|
Data,
|
||||||
|
Date,
|
||||||
|
DynamicLink,
|
||||||
|
File,
|
||||||
|
Float,
|
||||||
|
Int,
|
||||||
|
Link,
|
||||||
|
Password,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
}[this.docfield.fieldtype];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
13
ui/components/controls/Int.vue
Normal file
13
ui/components/controls/Int.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<script>
|
||||||
|
import Float from './Float';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Float,
|
||||||
|
methods: {
|
||||||
|
parse(value) {
|
||||||
|
const parsedValue = parseInt(value);
|
||||||
|
return isNaN(parsedValue) ? 0 : parsedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
89
ui/components/controls/Link.vue
Normal file
89
ui/components/controls/Link.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<script>
|
||||||
|
import frappe from 'frappejs';
|
||||||
|
import feather from 'feather-icons';
|
||||||
|
import Awesomplete from 'awesomplete';
|
||||||
|
import Autocomplete from './Autocomplete';
|
||||||
|
import Form from '../Form/Form';
|
||||||
|
import { _ } from 'frappejs/utils';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
newDoc.on('afterInsert', (data) => {
|
||||||
|
this.handleChange(newDoc.name);
|
||||||
|
this.$modal.hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
18
ui/components/controls/Password.vue
Normal file
18
ui/components/controls/Password.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script>
|
||||||
|
import Base from "./Base";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Password",
|
||||||
|
extends: Base,
|
||||||
|
methods: {
|
||||||
|
getInputAttrs() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
type: 'password',
|
||||||
|
value: this.value,
|
||||||
|
required: this.docfield.required
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
30
ui/components/controls/Select.vue
Normal file
30
ui/components/controls/Select.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
methods: {
|
||||||
|
getInputTag() {
|
||||||
|
return 'select';
|
||||||
|
},
|
||||||
|
getInputAttrs() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
required: this.docfield.required
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getInputChildren(h) {
|
||||||
|
return this.docfield.options.map(option =>
|
||||||
|
h('option', {
|
||||||
|
attrs: {
|
||||||
|
key: option,
|
||||||
|
selected: option === this.value
|
||||||
|
},
|
||||||
|
domProps: {
|
||||||
|
textContent: option
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
26
ui/components/controls/Table.vue
Normal file
26
ui/components/controls/Table.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<model-table
|
||||||
|
:doctype="docfield.childtype"
|
||||||
|
:rows="value"
|
||||||
|
@update:rows="emitChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ModelTable from '../ModelTable';
|
||||||
|
import Base from './Base';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
components: {
|
||||||
|
ModelTable
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange(rows, rowDoc) {
|
||||||
|
this.$emit('change', rows, rowDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
23
ui/components/controls/Text.vue
Normal file
23
ui/components/controls/Text.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script>
|
||||||
|
import Base from './Base';
|
||||||
|
export default {
|
||||||
|
extends: Base,
|
||||||
|
methods: {
|
||||||
|
getInputTag() {
|
||||||
|
return 'textarea';
|
||||||
|
},
|
||||||
|
getInputAttrs() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
required: this.docfield.required,
|
||||||
|
rows: 3
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDomProps() {
|
||||||
|
return {
|
||||||
|
value: this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
34
ui/pages/ListAndForm.vue
Normal file
34
ui/pages/ListAndForm.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="frappe-list-form row no-gutters">
|
||||||
|
<div class="col-4 border-right">
|
||||||
|
<frappe-list :doctype="doctype" :filters="filters" :key="doctype" />
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<frappe-form v-if="name" :key="doctype + name" :doctype="doctype" :name="name" @save="onSave" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import List from '../components/List/List';
|
||||||
|
import Form from '../components/Form/Form';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['doctype', 'name', 'filters'],
|
||||||
|
components: {
|
||||||
|
FrappeList: List,
|
||||||
|
FrappeForm: Form
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSave(doc) {
|
||||||
|
if (doc.name !== this.$route.params.name) {
|
||||||
|
this.$router.push(`/edit/${doc.doctype}/${doc.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.frappe-list-form {
|
||||||
|
min-height: calc(100vh - 4rem);
|
||||||
|
}
|
||||||
|
</style>
|
43
ui/pages/Report/ReportFilters.vue
Normal file
43
ui/pages/Report/ReportFilters.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row pb-4">
|
||||||
|
<frappe-control class="col-lg col-md-3 col-sm-6"
|
||||||
|
v-for="docfield in filters"
|
||||||
|
:key="docfield.fieldname"
|
||||||
|
:docfield="docfield"
|
||||||
|
:value="$data[docfield.fieldname]"
|
||||||
|
@change="updateValue(docfield.fieldname, $event)"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import FrappeControl from 'frappejs/ui/components/controls/FrappeControl'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['filters'],
|
||||||
|
data () {
|
||||||
|
const filterValues = {};
|
||||||
|
for (let filter of this.filters) {
|
||||||
|
filterValues[filter.fieldname] = '';
|
||||||
|
}
|
||||||
|
return {filterValues};
|
||||||
|
},
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
dynamicLinkTarget: (reference) => {
|
||||||
|
return this.filterValues[reference]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateValue(fieldname, value) {
|
||||||
|
this.filterValues[fieldname] = value;
|
||||||
|
this.$emit('change', this.filterValues)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
FrappeControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
44
ui/pages/Report/index.vue
Normal file
44
ui/pages/Report/index.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h4 class="pb-2">{{ reportConfig.title }}</h4>
|
||||||
|
<report-filters v-if="reportConfig.filterFields.length" :filters="reportConfig.filterFields" @change="getReportData"></report-filters>
|
||||||
|
<div class="pt-2" ref="datatable" v-once></div>
|
||||||
|
</div>
|
||||||
|
<not-found v-if="!reportConfig" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import DataTable from 'frappe-datatable'
|
||||||
|
import frappe from 'frappejs'
|
||||||
|
import ReportFilters from './ReportFilters'
|
||||||
|
import utils from 'frappejs/client/ui/utils'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['reportName', 'reportConfig'],
|
||||||
|
computed: {
|
||||||
|
reportColumns() {
|
||||||
|
return utils.convertFieldsToDatatableColumns(this.reportConfig.getColumns())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getReportData(filters) {
|
||||||
|
frappe.methods[this.reportConfig.method](filters).then(data => {
|
||||||
|
if (this.datatable) {
|
||||||
|
this.datatable.refresh(data || [])
|
||||||
|
} else {
|
||||||
|
this.datatable = new DataTable(this.$refs.datatable, {
|
||||||
|
columns: this.reportColumns,
|
||||||
|
data: data || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ReportFilters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
</style>
|
44
ui/plugins/modal.js
Normal file
44
ui/plugins/modal.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.mixin({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
registered: false,
|
||||||
|
modalVisible: false,
|
||||||
|
modalOptions: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.registered) return;
|
||||||
|
|
||||||
|
Bus.on('showModal', (options = {}) => {
|
||||||
|
this.modalVisible = true;
|
||||||
|
this.modalOptions = options;
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus.on('hideModal', () => {
|
||||||
|
this.modalVisible = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registered = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
16
ui/routes/index.js
Normal file
16
ui/routes/index.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import ListAndForm from '../pages/ListAndForm';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: '/list/:doctype',
|
||||||
|
name: 'List',
|
||||||
|
component: ListAndForm,
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/edit/:doctype/:name',
|
||||||
|
name: 'Form',
|
||||||
|
component: ListAndForm,
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
];
|
2
ui/styles/variables.scss
Normal file
2
ui/styles/variables.scss
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import "~bootstrap/scss/functions";
|
||||||
|
@import "~bootstrap/scss/variables";
|
Loading…
Reference in New Issue
Block a user