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:
Shariq Ansari 2022-10-11 19:42:27 +05:30
parent 4863ba154e
commit 5f64a2ace5
14 changed files with 394 additions and 1 deletions

View file

@ -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)}`);

View 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,
});
});
}
}

View 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"
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;

View 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);
},
},
});

View 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;
}

View file

@ -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",

View file

@ -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"