diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js
index e91a05e17d..f0ee1d02f9 100644
--- a/frappe/core/doctype/doctype/doctype.js
+++ b/frappe/core/doctype/doctype/doctype.js
@@ -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)}`);
diff --git a/frappe/desk/page/form_builder/__init__.py b/frappe/desk/page/form_builder/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/desk/page/form_builder/form_builder.js b/frappe/desk/page/form_builder/form_builder.js
new file mode 100644
index 0000000000..75a81772ad
--- /dev/null
+++ b/frappe/desk/page/form_builder/form_builder.js
@@ -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,
+ });
+ });
+ }
+}
diff --git a/frappe/desk/page/form_builder/form_builder.json b/frappe/desk/page/form_builder/form_builder.json
new file mode 100644
index 0000000000..afeacecd90
--- /dev/null
+++ b/frappe/desk/page/form_builder/form_builder.json
@@ -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"
+}
\ No newline at end of file
diff --git a/frappe/public/js/form_builder/components/FormBuilder.vue b/frappe/public/js/form_builder/components/FormBuilder.vue
new file mode 100644
index 0000000000..3e69518a8d
--- /dev/null
+++ b/frappe/public/js/form_builder/components/FormBuilder.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/frappe/public/js/form_builder/components/Layout.vue b/frappe/public/js/form_builder/components/Layout.vue
new file mode 100644
index 0000000000..08c51a4116
--- /dev/null
+++ b/frappe/public/js/form_builder/components/Layout.vue
@@ -0,0 +1,13 @@
+
+
+
+
+ Content in Tab 1
+ Content in Tab 2
+ Content in Tab 3
+ Content in Tab 4
+
+
diff --git a/frappe/public/js/form_builder/components/Sidebar.vue b/frappe/public/js/form_builder/components/Sidebar.vue
new file mode 100644
index 0000000000..dd1c71bb14
--- /dev/null
+++ b/frappe/public/js/form_builder/components/Sidebar.vue
@@ -0,0 +1,11 @@
+
+
+
+
+ Field Types
+ Field Meta
+
+
diff --git a/frappe/public/js/form_builder/components/Tab.vue b/frappe/public/js/form_builder/components/Tab.vue
new file mode 100644
index 0000000000..6cefbf3ef4
--- /dev/null
+++ b/frappe/public/js/form_builder/components/Tab.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/frappe/public/js/form_builder/components/Tabs.vue b/frappe/public/js/form_builder/components/Tabs.vue
new file mode 100644
index 0000000000..4db61e51af
--- /dev/null
+++ b/frappe/public/js/form_builder/components/Tabs.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/frappe/public/js/form_builder/form_builder.bundle.js b/frappe/public/js/form_builder/form_builder.bundle.js
new file mode 100644
index 0000000000..201f16cc45
--- /dev/null
+++ b/frappe/public/js/form_builder/form_builder.bundle.js
@@ -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;
diff --git a/frappe/public/js/form_builder/store.js b/frappe/public/js/form_builder/store.js
new file mode 100644
index 0000000000..a8a44960aa
--- /dev/null
+++ b/frappe/public/js/form_builder/store.js
@@ -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);
+ },
+ },
+});
diff --git a/frappe/public/js/form_builder/utils.js b/frappe/public/js/form_builder/utils.js
new file mode 100644
index 0000000000..b21f98af7e
--- /dev/null
+++ b/frappe/public/js/form_builder/utils.js
@@ -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;
+}
diff --git a/package.json b/package.json
index f92deb16a7..bd28995437 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 263c531ca4..4a7dbb8f98 100644
--- a/yarn.lock
+++ b/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"