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