feat: Form Builder
- Form Builder Page - Vue 3 setup - Pinia store setup - Reusable Tab Component - Form Builder button on Doctype
This commit is contained in:
parent
4863ba154e
commit
5f64a2ace5
14 changed files with 394 additions and 1 deletions
|
|
@ -22,6 +22,10 @@ frappe.ui.form.on("DocType", {
|
|||
}
|
||||
|
||||
if (!frm.is_new() && !frm.doc.istable) {
|
||||
frm.add_custom_button(__("Try new form builder", [__(frm.doc.name)]), () => {
|
||||
frappe.set_route("form-builder", frappe.router.slug(frm.doc.name));
|
||||
});
|
||||
|
||||
if (frm.doc.issingle) {
|
||||
frm.add_custom_button(__("Go to {0}", [__(frm.doc.name)]), () => {
|
||||
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
|
||||
|
|
|
|||
0
frappe/desk/page/form_builder/__init__.py
Normal file
0
frappe/desk/page/form_builder/__init__.py
Normal file
37
frappe/desk/page/form_builder/form_builder.js
Normal file
37
frappe/desk/page/form_builder/form_builder.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
frappe.pages["form-builder"].on_page_load = function (wrapper) {
|
||||
frappe.ui.make_app_page({
|
||||
parent: wrapper,
|
||||
title: __("Form Builder"),
|
||||
single_column: true,
|
||||
});
|
||||
|
||||
// hot reload in development
|
||||
if (frappe.boot.developer_mode) {
|
||||
frappe.hot_update = frappe.hot_update || [];
|
||||
frappe.hot_update.push(() => load_form_builder(wrapper));
|
||||
}
|
||||
};
|
||||
|
||||
frappe.pages["form-builder"].on_page_show = function (wrapper) {
|
||||
load_form_builder(wrapper);
|
||||
};
|
||||
|
||||
function load_form_builder(wrapper) {
|
||||
let route = frappe.get_route();
|
||||
if (route.length > 1) {
|
||||
let doctype = frappe.router.routes[route[1]].doctype;
|
||||
|
||||
if (frappe.form_builder?.doctype == doctype) return;
|
||||
|
||||
let $parent = $(wrapper).find(".layout-main-section");
|
||||
$parent.empty();
|
||||
|
||||
frappe.require("form_builder.bundle.js").then(() => {
|
||||
frappe.form_builder = new frappe.ui.FormBuilder({
|
||||
wrapper: $parent,
|
||||
page: wrapper.page,
|
||||
doctype: doctype,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
19
frappe/desk/page/form_builder/form_builder.json
Normal file
19
frappe/desk/page/form_builder/form_builder.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"content": null,
|
||||
"creation": "2022-10-10 22:42:53.597423",
|
||||
"docstatus": 0,
|
||||
"doctype": "Page",
|
||||
"idx": 0,
|
||||
"modified": "2022-10-10 22:42:53.597423",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "form-builder",
|
||||
"owner": "Administrator",
|
||||
"page_name": "form-builder",
|
||||
"roles": [],
|
||||
"script": null,
|
||||
"standard": "Yes",
|
||||
"style": null,
|
||||
"system_page": 0,
|
||||
"title": "Form Builder"
|
||||
}
|
||||
21
frappe/public/js/form_builder/components/FormBuilder.vue
Normal file
21
frappe/public/js/form_builder/components/FormBuilder.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
import Sidebar from "./Sidebar.vue";
|
||||
import Layout from "./Layout.vue";
|
||||
import { onMounted } from "vue";
|
||||
import { useStore } from "../store";
|
||||
|
||||
let store = useStore();
|
||||
|
||||
onMounted(() => store.fetch());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-main-section row">
|
||||
<div class="form-controls col-3">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div class="form-container col-9">
|
||||
<Layout />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
13
frappe/public/js/form_builder/components/Layout.vue
Normal file
13
frappe/public/js/form_builder/components/Layout.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup>
|
||||
import Tabs from "./Tabs.vue";
|
||||
import Tab from "./Tab.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs>
|
||||
<Tab title="Tab 1">Content in Tab 1</Tab>
|
||||
<Tab title="Tab 2">Content in Tab 2</Tab>
|
||||
<Tab title="Tab 3">Content in Tab 3</Tab>
|
||||
<Tab title="Tab 4">Content in Tab 4</Tab>
|
||||
</Tabs>
|
||||
</template>
|
||||
11
frappe/public/js/form_builder/components/Sidebar.vue
Normal file
11
frappe/public/js/form_builder/components/Sidebar.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script setup>
|
||||
import Tabs from "./Tabs.vue";
|
||||
import Tab from "./Tab.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs>
|
||||
<Tab title="Field Types">Field Types</Tab>
|
||||
<Tab title="Field Meta">Field Meta</Tab>
|
||||
</Tabs>
|
||||
</template>
|
||||
18
frappe/public/js/form_builder/components/Tab.vue
Normal file
18
frappe/public/js/form_builder/components/Tab.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup>
|
||||
import { inject } from "vue";
|
||||
|
||||
let props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
let selected_tab = inject("selected_tab");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="selected_tab == title">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
frappe/public/js/form_builder/components/Tabs.vue
Normal file
21
frappe/public/js/form_builder/components/Tabs.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
import { ref, useSlots, provide } from "vue";
|
||||
|
||||
let slots = useSlots();
|
||||
|
||||
let tab_titles = ref(slots.default().map(tab => tab.props.title));
|
||||
let selected_tab = ref(tab_titles.value[0]);
|
||||
|
||||
provide("selected_tab", selected_tab);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs">
|
||||
<div class="tab-header">
|
||||
<div v-for="title in tab_titles" :key="title" @click="selected_tab = title">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
63
frappe/public/js/form_builder/form_builder.bundle.js
Normal file
63
frappe/public/js/form_builder/form_builder.bundle.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { createApp, watch } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { useStore } from "./store";
|
||||
import FormBuilderComponent from "./components/FormBuilder.vue";
|
||||
|
||||
class FormBuilder {
|
||||
constructor({ wrapper, page, doctype }) {
|
||||
this.$wrapper = $(wrapper);
|
||||
this.page = page;
|
||||
this.doctype = doctype;
|
||||
|
||||
// clear actions
|
||||
this.page.clear_actions();
|
||||
this.page.clear_menu();
|
||||
this.page.clear_custom_actions();
|
||||
|
||||
// set page title
|
||||
this.page.set_title(__("Form Builder: {0}", [this.doctype]));
|
||||
|
||||
// setup page actions
|
||||
let $reset_changes_btn = this.page.add_button(__("Reset Changes"), () =>
|
||||
store.reset_changes()
|
||||
);
|
||||
|
||||
this.page.add_menu_item(__("Go to {0} Doctype", [this.doctype]), () =>
|
||||
frappe.set_route("Form", "DocType", this.doctype)
|
||||
);
|
||||
|
||||
// create a pinia instance
|
||||
let pinia = createPinia();
|
||||
|
||||
// create a vue instance
|
||||
let app = createApp(FormBuilderComponent);
|
||||
SetVueGlobals(app);
|
||||
app.use(pinia);
|
||||
|
||||
// create a store
|
||||
let store = useStore();
|
||||
store.doctype = this.doctype;
|
||||
|
||||
// mount the app
|
||||
this.$form_builder = app.mount(this.$wrapper.get(0));
|
||||
|
||||
// watch for changes
|
||||
watch(
|
||||
() => store.dirty,
|
||||
(dirty) => {
|
||||
if (dirty) {
|
||||
this.page.set_indicator("Not Saved", "orange");
|
||||
$reset_changes_btn.show();
|
||||
} else {
|
||||
this.page.clear_indicator();
|
||||
$reset_changes_btn.hide();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
frappe.provide("frappe.ui");
|
||||
frappe.ui.FormBuilder = FormBuilder;
|
||||
export default FormBuilder;
|
||||
38
frappe/public/js/form_builder/store.js
Normal file
38
frappe/public/js/form_builder/store.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { create_layout } from "./utils";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
export const useStore = defineStore("store", {
|
||||
state: () => ({
|
||||
doctype: "",
|
||||
fields: [],
|
||||
docfields: [],
|
||||
layout: {},
|
||||
dirty: false,
|
||||
}),
|
||||
actions: {
|
||||
is_dirty() {
|
||||
return this.dirty;
|
||||
},
|
||||
fetch() {
|
||||
return new Promise((resolve) => {
|
||||
frappe.model.clear_doc("DocType", this.doctype);
|
||||
frappe.model.with_doctype(this.doctype, () => {
|
||||
this.fields = frappe.get_meta(this.doctype).fields;
|
||||
frappe.model.with_doctype("DocField", () => {
|
||||
this.docfields = frappe.get_meta("DocField").fields;
|
||||
this.layout = this.get_layout();
|
||||
nextTick(() => (this.dirty = false));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
reset_changes() {
|
||||
this.fetch();
|
||||
},
|
||||
get_layout() {
|
||||
return create_layout(this.fields);
|
||||
},
|
||||
},
|
||||
});
|
||||
134
frappe/public/js/form_builder/utils.js
Normal file
134
frappe/public/js/form_builder/utils.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
export function create_layout(fields) {
|
||||
let layout = {
|
||||
tabs: [],
|
||||
};
|
||||
|
||||
let tab = null,
|
||||
section = null,
|
||||
column = null;
|
||||
|
||||
function set_tab(df) {
|
||||
tab = get_new_tab(df);
|
||||
column = null;
|
||||
section = null;
|
||||
layout.tabs.push(tab);
|
||||
}
|
||||
|
||||
function set_column(df) {
|
||||
if (!section) {
|
||||
set_section();
|
||||
}
|
||||
column = get_new_column(df);
|
||||
section.columns.push(column);
|
||||
}
|
||||
|
||||
function set_section(df) {
|
||||
if (!tab) {
|
||||
set_tab();
|
||||
}
|
||||
section = get_new_section(df, tab);
|
||||
column = null;
|
||||
tab.sections.push(section);
|
||||
}
|
||||
|
||||
function get_new_tab(df) {
|
||||
let field = {};
|
||||
if (!df) {
|
||||
df = {
|
||||
label: __("Details"),
|
||||
fieldtype: "Tab Break",
|
||||
};
|
||||
field.new_field = true;
|
||||
}
|
||||
field.df = df;
|
||||
field.sections = [];
|
||||
return field;
|
||||
}
|
||||
|
||||
function get_new_section(df) {
|
||||
let field = {};
|
||||
if (!df) {
|
||||
df = { fieldtype: "Section Break" };
|
||||
field.new_field = true;
|
||||
}
|
||||
field.df = df;
|
||||
field.columns = [];
|
||||
return field;
|
||||
}
|
||||
|
||||
function get_new_column(df) {
|
||||
let field = {};
|
||||
if (!df) {
|
||||
df = { fieldtype: "Column Break" };
|
||||
field.new_field = true;
|
||||
}
|
||||
field.df = df;
|
||||
field.fields = [];
|
||||
return field;
|
||||
}
|
||||
|
||||
for (let df of fields) {
|
||||
if (df.fieldname) {
|
||||
// make a copy to avoid mutation bugs
|
||||
df = JSON.parse(JSON.stringify(df));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (df.fieldtype === "Tab Break") {
|
||||
set_tab(df);
|
||||
} else if (df.fieldtype === "Section Break") {
|
||||
set_section(df);
|
||||
} else if (df.fieldtype === "Column Break") {
|
||||
set_column(df);
|
||||
} else if (df.name) {
|
||||
if (!column) set_column();
|
||||
|
||||
let field = { df: df };
|
||||
|
||||
if (df.fieldtype === "Table") {
|
||||
field.table_columns = get_table_columns(df);
|
||||
}
|
||||
|
||||
column.fields.push(field);
|
||||
section.has_fields = true;
|
||||
}
|
||||
}
|
||||
|
||||
// remove empty sections
|
||||
for (let tab of layout.tabs) {
|
||||
for (let section of tab.sections) {
|
||||
if (!section.has_fields) {
|
||||
tab.sections.splice(tab.sections.indexOf(section), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function get_table_columns(df) {
|
||||
let table_columns = [];
|
||||
let table_fields = frappe.get_meta(df.options).fields;
|
||||
let total_width = 0;
|
||||
for (let tf of table_fields) {
|
||||
if (
|
||||
!in_list(["Tab Break", "Section Break", "Column Break", "Fold"], tf.fieldtype) &&
|
||||
!tf.print_hide &&
|
||||
df.label &&
|
||||
total_width < 100
|
||||
) {
|
||||
let width =
|
||||
typeof tf.width == "number" && tf.width < 100 ? tf.width : tf.width ? 20 : 10;
|
||||
table_columns.push({
|
||||
label: tf.label,
|
||||
fieldname: tf.fieldname,
|
||||
fieldtype: tf.fieldtype,
|
||||
options: tf.options,
|
||||
width,
|
||||
});
|
||||
total_width += width;
|
||||
}
|
||||
}
|
||||
return table_columns;
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
"vue-router": "^4.1.5",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "4.0.2",
|
||||
"pinia": "^2.0.23",
|
||||
"@frappe/esbuild-plugin-postcss2": "^0.1.3",
|
||||
"@vue/component-compiler": "^4.2.4",
|
||||
"autoprefixer": "10",
|
||||
|
|
|
|||
15
yarn.lock
15
yarn.lock
|
|
@ -214,7 +214,7 @@
|
|||
sass "^1.18.0"
|
||||
stylus "^0.54.5"
|
||||
|
||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4":
|
||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4", "@vue/devtools-api@^6.4.4":
|
||||
version "6.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.4.4.tgz#0b024fc8ca91bb4b6035abaf53c5aecc17119b3b"
|
||||
integrity sha512-Ku31WzpOV/8cruFaXaEZKF81WkNnvCSlBY4eOGtz5WMSdJvX1v1WWlSMGZeqUwPtQ27ZZz7B62erEMq8JDjcXw==
|
||||
|
|
@ -2357,6 +2357,14 @@ pify@^4.0.1:
|
|||
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
|
||||
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
|
||||
|
||||
pinia@^2.0.23:
|
||||
version "2.0.23"
|
||||
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.23.tgz#570f5f82160b656b412602789683faa95502d227"
|
||||
integrity sha512-N15hFf4o5STrxpNrib1IEb1GOArvPYf1zPvQVRGOO1G1d74Ak0J0lVyalX/SmrzdT4Q0nlEFjbURsmBmIGUR5Q==
|
||||
dependencies:
|
||||
"@vue/devtools-api" "^6.4.4"
|
||||
vue-demi "*"
|
||||
|
||||
plyr@^3.7.2:
|
||||
version "3.7.2"
|
||||
resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.7.2.tgz#183d2397e7401a577700c8319fe133692b4aff54"
|
||||
|
|
@ -3533,6 +3541,11 @@ void-elements@^3.1.0:
|
|||
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
|
||||
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
|
||||
|
||||
vue-demi@*:
|
||||
version "0.13.11"
|
||||
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"
|
||||
integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==
|
||||
|
||||
vue-router@^4.1.5:
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.5.tgz#256f597e3f5a281a23352a6193aa6e342c8d9f9a"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue