Merge pull request #21377 from shariquerik/move-form-builder-in-doctype-form

This commit is contained in:
Shariq Ansari 2023-07-16 14:38:08 +05:30 committed by GitHub
commit d423dedcd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 360 additions and 671 deletions

View file

@ -9,38 +9,23 @@ context("Form Builder", () => {
it("Open Form Builder for Web Form Doctype/Customize Form", () => {
// doctype
cy.visit("/app/form-builder/Web Form");
cy.visit("/app/doctype/Web Form");
cy.findByRole("tab", { name: "Form" }).click();
cy.get(".form-builder-container").should("exist");
// customize form
cy.visit("/app/form-builder/Web Form/customize");
cy.visit("/app/customize-form?doc_type=Web%20Form");
cy.findByRole("tab", { name: "Form" }).click();
cy.get(".form-builder-container").should("exist");
});
it("Change Doctype using page title dialog", () => {
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");
cy.visit(`/app/form-builder/Web Form`);
cy.get(".form-builder-container").should("exist");
cy.get(".page-title").click();
cy.get(".frappe-control[data-fieldname='doctype'] input").click().as("input");
cy.get("@input").type("{rightArrow}Web Form Field", { delay: 200 });
cy.wait("@search_link");
cy.get("@input").type("{enter}").blur();
cy.click_modal_primary_button("Edit");
cy.get(".page-title .title-text").should("have.text", "Web Form Field");
});
it("Save without change, check form dirty and reset changes", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
it("Save without change, check form dirty", () => {
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
// Save without change
cy.click_doc_primary_button("Save");
cy.get(".desk-alert.orange .alert-message").should("have.text", "No changes to save");
cy.get(".desk-alert.orange .alert-message").should("have.text", "No changes in document");
// Check form dirty
cy.get(".tab-content.active .section-columns-container:first .column:first .field:first")
@ -48,14 +33,11 @@ context("Form Builder", () => {
.dblclick()
.type("Dirty");
cy.get(".title-area .indicator-pill.orange").should("have.text", "Not Saved");
// Reset changes
cy.get(".page-actions .custom-actions .btn").contains("Reset Changes").click();
cy.get(".title-area .indicator-pill.orange").should("not.exist");
});
it("Add empty section and save", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_section = ".tab-content.active .form-section-container:first";
@ -71,7 +53,8 @@ context("Form Builder", () => {
it("Add Table field and check if columns are rendered", () => {
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
@ -126,20 +109,23 @@ context("Form Builder", () => {
});
it("Drag Field/Column/Section & Tab", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
let first_field = first_column + " .field:first";
let label = "div[title='Double click to edit label'] span:first";
cy.get(".tab-header .tabs .tab:first").click();
// drag first tab to second position
cy.get(".tabs .tab:first").drag(".tabs .tab:nth-child(2)", {
cy.get(".tab-header .tabs .tab:first").drag(".tab-header .tabs .tab:nth-child(2)", {
target: { x: 10, y: 10 },
force: true,
});
cy.get(".tabs .tab:first").find(label).should("have.text", "Tab 2");
cy.get(".tab-header .tabs .tab:first").find(label).should("have.text", "Tab 2");
cy.get(".tabs .tab:first").click();
cy.get(".tab-header .tabs .tab:first").click();
cy.get(".sidebar-container .tab:first").click();
// drag check field to first column
@ -151,7 +137,7 @@ context("Form Builder", () => {
cy.get(first_field)
.find("div[title='Double click to edit label']")
.dblclick()
.type("Test Check{enter}");
.type("Test Check");
cy.get(first_field).find(label).should("have.text", "Test Check");
// drag the first field to second position
@ -184,13 +170,14 @@ context("Form Builder", () => {
});
it("Add New Tab/Section/Column to Form", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_section = ".tab-content.active .form-section-container:first";
// add new tab
cy.get(".tab-header").realHover().find(".tab-actions .new-tab-btn").click();
cy.get(".tabs .tab").should("have.length", 3);
cy.get(".tab-header .tabs .tab").should("have.length", 3);
// add new section
cy.get(first_section).click(15, 10);
@ -218,11 +205,12 @@ context("Form Builder", () => {
// remove tab
cy.get(".tab-header").realHover().find(".tab-actions .remove-tab-btn").click();
cy.get(".tabs .tab").should("have.length", 2);
cy.get(".tab-header .tabs .tab").should("have.length", 2);
});
it("Update Title field Label to New Title through Customize Form", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
@ -239,7 +227,8 @@ context("Form Builder", () => {
});
it("Validate Duplicate Name & reqd + hidden without default logic", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
@ -275,10 +264,11 @@ context("Form Builder", () => {
});
it("Undo/Redo", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
cy.visit(`/app/doctype/${doctype_name}`);
cy.findByRole("tab", { name: "Form" }).click();
// click on second tab
cy.get(".tabs .tab:last").click();
cy.get(".tab-header .tabs .tab:last").click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
let first_field = first_column + " .field:first";

View file

@ -4,6 +4,8 @@ context("Grid Configuration", () => {
cy.visit("/app/doctype/User");
});
it("Set user wise grid settings", () => {
cy.findByRole("tab", { name: "Form" }).click();
cy.get('.form-section[data-fieldname="fields_section"]').click();
cy.wait(100);
cy.get('.frappe-control[data-fieldname="fields"]').as("table");
cy.get("@table").find(".icon-sm").click();

View file

@ -27,60 +27,70 @@ context("Sidebar", () => {
});
it("Verify attachment visibility config", () => {
verify_attachment_visibility("doctype/Blog Post", true);
cy.call("frappe.tests.ui_test_helpers.create_todo", {
description: "Sidebar Attachment ToDo",
}).then((todo) => {
verify_attachment_visibility(`todo/${todo.message.name}`, true);
});
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
});
it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => {
cy.visit("/app/doctype");
cy.click_sidebar_button("Assigned To");
cy.call("frappe.tests.ui_test_helpers.create_todo", {
description: "Sidebar Attachment ToDo",
}).then((todo) => {
let todo_name = todo.message.name;
cy.visit("/app/todo");
cy.click_sidebar_button("Assigned To");
//To check if no filter is available in "Assigned To" dropdown
cy.get(".empty-state").should("contain", "No filters found");
//To check if no filter is available in "Assigned To" dropdown
cy.get(".empty-state").should("contain", "No filters found");
//Assigning a doctype to a user
cy.visit("/app/doctype/ToDo");
cy.get(".form-assignments > .flex > .text-muted").click();
cy.get_field("assign_to_me", "Check").click();
cy.get(".modal-footer > .standard-actions > .btn-primary").click();
cy.visit("/app/doctype");
cy.click_sidebar_button("Assigned To");
//Assigning a doctype to a user
cy.visit(`/app/todo/${todo_name}`);
cy.get(".form-assignments > .flex > .text-muted").click();
cy.get_field("assign_to_me", "Check").click();
cy.get(".modal-footer > .standard-actions > .btn-primary").click();
cy.visit("/app/todo");
cy.click_sidebar_button("Assigned To");
//To check if filter is added in "Assigned To" dropdown after assignment
cy.get(".group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item").should(
"contain",
"1"
);
//To check if filter is added in "Assigned To" dropdown after assignment
cy.get(
".group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item"
).should("contain", "1");
//To check if there is no filter added to the listview
cy.get(".filter-button").should("contain", "Filter");
//To check if there is no filter added to the listview
cy.get(".filter-button").should("contain", "Filter");
//To add a filter to display data into the listview
cy.get(".group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item").click();
//To add a filter to display data into the listview
cy.get(
".group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item"
).click();
//To check if filter is applied
cy.click_filter_button().should("contain", "1 filter");
cy.get(".fieldname-select-area > .awesomplete > .form-control").should(
"have.value",
"Assigned To"
);
cy.get(".condition").should("have.value", "like");
cy.get(".filter-field > .form-group > .input-with-feedback").should(
"have.value",
`%${cy.config("testUser")}%`
);
cy.click_filter_button();
//To check if filter is applied
cy.click_filter_button().should("contain", "1 filter");
cy.get(".fieldname-select-area > .awesomplete > .form-control").should(
"have.value",
"Assigned To"
);
cy.get(".condition").should("have.value", "like");
cy.get(".filter-field > .form-group > .input-with-feedback").should(
"have.value",
`%${cy.config("testUser")}%`
);
cy.click_filter_button();
//To remove the applied filter
cy.clear_filters();
//To remove the applied filter
cy.clear_filters();
//To remove the assignment
cy.visit("/app/doctype/ToDo");
cy.get(".assignments > .avatar-group > .avatar > .avatar-frame").click();
cy.get(".remove-btn").click({ force: true });
cy.hide_dialog();
cy.visit("/app/doctype");
cy.click_sidebar_button("Assigned To");
cy.get(".empty-state").should("contain", "No filters found");
//To remove the assignment
cy.visit(`/app/todo/${todo_name}`);
cy.get(".assignments > .avatar-group > .avatar > .avatar-frame").click();
cy.get(".remove-btn").click({ force: true });
cy.hide_dialog();
cy.visit("/app/todo");
cy.click_sidebar_button("Assigned To");
cy.get(".empty-state").should("contain", "No filters found");
});
});
});

View file

@ -2,6 +2,26 @@
// MIT License. See license.txt
frappe.ui.form.on("DocType", {
before_save: function (frm) {
let form_builder = frappe.form_builder;
if (form_builder?.store) {
let fields = form_builder.store.update_fields();
// if fields is a string, it means there is an error
if (typeof fields === "string") {
frappe.throw(fields);
}
}
},
after_save: function (frm) {
if (
frappe.form_builder &&
frappe.form_builder.doctype === frm.doc.name &&
frappe.form_builder.store
) {
frappe.form_builder.store.fetch();
}
},
refresh: function (frm) {
frm.set_query("role", "permissions", function (doc) {
if (doc.custom && frappe.session.user != "Administrator") {
@ -21,8 +41,6 @@ frappe.ui.form.on("DocType", {
frm.toggle_enable("beta", 0);
}
render_form_builder_message(frm);
if (!frm.is_new() && !frm.doc.istable) {
if (frm.doc.issingle) {
frm.add_custom_button(__("Go to {0}", [__(frm.doc.name)]), () => {
@ -72,6 +90,8 @@ frappe.ui.form.on("DocType", {
frm.cscript.autoname(frm);
frm.cscript.set_naming_rule_description(frm);
frm.trigger("setup_default_views");
render_form_builder(frm);
},
istable: (frm) => {
@ -142,4 +162,30 @@ function render_form_builder_message(frm) {
}
}
function render_form_builder(frm) {
if (frappe.form_builder && frappe.form_builder.doctype === frm.doc.name) {
frappe.form_builder.setup_page_actions();
frappe.form_builder.store.fetch();
return;
}
if (frappe.form_builder) {
frappe.form_builder.wrapper = $(frm.fields_dict["form_builder"].wrapper);
frappe.form_builder.frm = frm;
frappe.form_builder.doctype = frm.doc.name;
frappe.form_builder.customize = false;
frappe.form_builder.init(true);
frappe.form_builder.store.fetch();
} else {
frappe.require("form_builder.bundle.js").then(() => {
frappe.form_builder = new frappe.ui.FormBuilder({
wrapper: $(frm.fields_dict["form_builder"].wrapper),
frm: frm,
doctype: frm.doc.name,
customize: false,
});
});
}
}
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({ frm: cur_frm }));

View file

@ -25,9 +25,6 @@
"beta",
"is_virtual",
"queue_in_background",
"fields_section_break",
"try_form_builder_html",
"fields",
"sb1",
"naming_rule",
"autoname",
@ -35,6 +32,32 @@
"column_break_15",
"description",
"documentation",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash",
"form_builder_tab",
"form_builder",
"fields_section",
"fields",
"settings_tab",
"form_settings_section",
"image_field",
"timeline_field",
@ -68,28 +91,7 @@
"column_break_51",
"email_append_to",
"sender_field",
"subject_field",
"sb2",
"permissions",
"restrict_to_domain",
"read_only",
"in_create",
"actions_section",
"actions",
"links_section",
"links",
"document_states_section",
"states",
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"website_search_field",
"advanced",
"engine",
"migration_hash"
"subject_field"
],
"fields": [
{
@ -195,12 +197,6 @@
"fieldtype": "Check",
"label": "Beta"
},
{
"fieldname": "fields_section_break",
"fieldtype": "Section Break",
"label": "Fields",
"oldfieldtype": "Section Break"
},
{
"fieldname": "fields",
"fieldtype": "Table",
@ -633,9 +629,25 @@
"label": "Is Calendar and Gantt"
},
{
"fieldname": "try_form_builder_html",
"fieldname": "settings_tab",
"fieldtype": "Tab Break",
"label": "Settings"
},
{
"fieldname": "form_builder_tab",
"fieldtype": "Tab Break",
"label": "Form"
},
{
"fieldname": "form_builder",
"fieldtype": "HTML",
"label": "Try Form Builder HTML"
"label": "Form Builder"
},
{
"collapsible": 1,
"fieldname": "fields_section",
"fieldtype": "Section Break",
"label": "Fields"
}
],
"icon": "fa fa-bolt",
@ -718,7 +730,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2023-05-15 14:07:51.526257",
"modified": "2023-07-12 13:56:26.185637",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -755,4 +767,4 @@
"states": [],
"track_changes": 1,
"translated_doctype": 1
}
}

View file

@ -1,28 +0,0 @@
frappe.listview_settings["DocType"] = {
onload: function (me) {
me.page.btn_primary.addClass("hidden");
this.setup_select_primary_button(me);
},
setup_select_primary_button: function (me) {
let actions = [
{
label: __("Add DocType (Form Builder)"),
description: __("Use the form builder to create a new DocType"),
action: () => frappe.set_route("form-builder", "new-doctype"),
},
{
label: __("Add DocType"),
description: __("Create a new DocType"),
action: () => frappe.new_doc("DocType"),
},
];
frappe.utils.add_select_group_button(
me.page.btn_primary.parent(),
actions,
"btn-primary",
"add"
);
},
};

View file

@ -100,8 +100,6 @@ frappe.ui.form.on("Customize Form", {
frm.page.set_title(__("Customize Form - {0}", [frm.doc.doc_type]));
frappe.customize_form.set_primary_action(frm);
render_form_builder_message(frm);
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
@ -149,6 +147,8 @@ frappe.ui.form.on("Customize Form", {
["queue_in_background"],
frappe.get_meta(frm.doc.doc_type).is_submittable || 0
);
render_form_builder(frm);
});
}
@ -334,37 +334,6 @@ frappe.ui.form.on("DocType State", {
},
});
frappe.customize_form.validate_fieldnames = async function (frm) {
for (let i = 0; i < frm.doc.fields.length; i++) {
let field = frm.doc.fields[i];
let fieldname = field.label && frappe.model.scrub(field.label).toLowerCase();
if (
field.label &&
!field.fieldname &&
in_list(frappe.model.restricted_fields, fieldname)
) {
let message = __(
"For field <b>{0}</b> in row <b>{1}</b>, fieldname <b>{2}</b> is restricted it will be renamed as <b>{2}1</b>. Do you want to continue?",
[field.label, field.idx, fieldname]
);
await pause_to_confirm(message);
}
}
function pause_to_confirm(message) {
return new Promise((resolve) => {
frappe.confirm(
message,
() => resolve(),
() => {
frm.page.btn_primary.prop("disabled", false);
}
);
});
}
};
frappe.customize_form.save_customization = function (frm) {
if (frm.doc.doc_type) {
return frm.call({
@ -383,9 +352,22 @@ frappe.customize_form.save_customization = function (frm) {
}
};
frappe.customize_form.update_fields_from_form_builder = function (frm) {
let form_builder = frappe.form_builder;
if (form_builder?.store) {
let fields = form_builder.store.update_fields();
// if fields is a string, it means there is an error
if (typeof fields === "string") {
frappe.throw(fields);
}
frm.refresh_fields();
}
};
frappe.customize_form.set_primary_action = function (frm) {
frm.page.set_primary_action(__("Update"), async () => {
await this.validate_fieldnames(frm);
frm.page.set_primary_action(__("Update"), () => {
this.update_fields_from_form_builder(frm);
this.save_customization(frm);
});
};
@ -433,30 +415,29 @@ frappe.customize_form.clear_locals_and_refresh = function (frm) {
frm.refresh();
};
function render_form_builder_message(frm) {
$(frm.fields_dict["try_form_builder_html"].wrapper).empty();
if (!frm.is_new() && frm.fields_dict["try_form_builder_html"]) {
let title = __("Use Form Builder to visually customize your form layout");
let msg = __(
"You can drag and drop fields to create your form layout, add tabs, sections and columns to organize your form and update field properties all from one screen."
);
function render_form_builder(frm) {
if (frappe.form_builder && frappe.form_builder.doctype === frm.doc.doc_type) {
frappe.form_builder.setup_page_actions();
frappe.form_builder.store.fetch();
return;
}
let message = `
<div class="flex form-message blue p-3">
<div class="mr-3"><img style="border-radius: var(--border-radius-md)" width="275" src="/assets/frappe/images/form-builder.gif"></div>
<div>
<p style="font-size: var(--text-lg)">${title}</p>
<p>${msg}</p>
<div>
<a class="btn btn-primary btn-sm" href="/app/form-builder/${frm.doc.doc_type}/customize">
${__("Form Builder")} ${frappe.utils.icon("right", "xs")}
</a>
</div>
</div>
</div>
`;
$(frm.fields_dict["try_form_builder_html"].wrapper).html(message);
if (frappe.form_builder) {
frappe.form_builder.wrapper = $(frm.fields_dict["form_builder"].wrapper);
frappe.form_builder.frm = frm;
frappe.form_builder.doctype = frm.doc.doc_type;
frappe.form_builder.customize = true;
frappe.form_builder.init(true);
frappe.form_builder.store.fetch();
} else {
frappe.require("form_builder.bundle.js").then(() => {
frappe.form_builder = new frappe.ui.FormBuilder({
wrapper: $(frm.fields_dict["form_builder"].wrapper),
frm: frm,
doctype: frm.doc.doc_type,
customize: true,
});
});
}
}

View file

@ -21,12 +21,20 @@
"allow_auto_repeat",
"allow_import",
"queue_in_background",
"fields_section_break",
"try_form_builder_html",
"fields",
"naming_section",
"naming_rule",
"autoname",
"document_actions_section",
"actions",
"document_links_section",
"links",
"document_states_section",
"states",
"form_tab",
"form_builder",
"fields_section_break",
"fields",
"settings_tab",
"form_settings_section",
"image_field",
"max_attachments",
@ -48,12 +56,6 @@
"email_append_to",
"sender_field",
"subject_field",
"document_actions_section",
"actions",
"document_links_section",
"links",
"document_states_section",
"states",
"section_break_8",
"sort_field",
"column_break_10",
@ -174,8 +176,8 @@
"options": "ASC\nDESC"
},
{
"collapsible": 1,
"depends_on": "doc_type",
"description": "Customize Label, Print Hide, Default etc.",
"fieldname": "fields_section_break",
"fieldtype": "Section Break",
"label": "Fields"
@ -369,9 +371,19 @@
"label": "Is Calendar and Gantt"
},
{
"fieldname": "try_form_builder_html",
"fieldname": "settings_tab",
"fieldtype": "Tab Break",
"label": "Settings"
},
{
"fieldname": "form_builder",
"fieldtype": "HTML",
"label": "Try Form Builder HTML"
"label": "Form Builder"
},
{
"fieldname": "form_tab",
"fieldtype": "Tab Break",
"label": "Form"
}
],
"hide_toolbar": 1,
@ -380,7 +392,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-05-15 16:03:19.872532",
"modified": "2023-07-16 13:25:46.201184",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -1,211 +0,0 @@
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();
route = route.filter((a) => a);
if (route.length > 1 && route[1] === "new-doctype") {
frappe.pages["form-builder"].new_doctype(route[2]);
} else if (route.length > 1) {
let doctype = route[1];
let is_customize_form = route[2] === "customize";
if (frappe.form_builder?.doctype) {
frappe.form_builder.doctype = frappe.form_builder.store.doctype = doctype;
frappe.form_builder.customize = frappe.form_builder.store.is_customize_form =
is_customize_form;
frappe.form_builder.init(true);
frappe.form_builder.store.fetch();
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,
customize: is_customize_form,
});
});
} else {
frappe.pages["form-builder"].select_doctype();
}
}
frappe.pages["form-builder"].select_doctype = function () {
let d = new frappe.ui.Dialog({
title: __("Select DocType"),
fields: [
{
label: __("Select DocType"),
fieldname: "doctype",
fieldtype: "Link",
options: "DocType",
only_select: 1,
},
{
label: __("Customize"),
fieldname: "customize",
fieldtype: "Check",
},
],
primary_action_label: __("Edit"),
primary_action({ doctype, customize }) {
if (customize) {
frappe.model.with_doctype(doctype).then(() => {
let meta = frappe.get_meta(doctype);
if (in_list(frappe.model.core_doctypes_list, this.doctype))
frappe.throw(__("Core DocTypes cannot be customized."));
if (meta.issingle) frappe.throw(__("Single DocTypes cannot be customized."));
if (meta.custom)
frappe.throw(
__(
"Only standard DocTypes are allowed to be customized from Customize Form."
)
);
frappe.set_route("form-builder", doctype, "customize");
});
} else {
frappe.set_route("form-builder", doctype);
}
},
secondary_action_label: __("Create New DocType"),
secondary_action() {
let doctype = d.get_value("doctype") || "";
d.hide();
frappe.set_route("form-builder", "new-doctype", doctype);
},
});
d.show();
};
frappe.pages["form-builder"].new_doctype = function (doctype) {
let non_developer = frappe.session.user !== "Administrator" || !frappe.boot.developer_mode;
let new_d = new frappe.ui.Dialog({
title: __("Create New DocType"),
fields: [
{
label: __("DocType Name"),
fieldname: "doctype_name",
fieldtype: "Data",
default: doctype,
reqd: 1,
},
{ fieldtype: "Column Break" },
{
label: __("Module"),
fieldname: "module",
fieldtype: "Link",
options: "Module Def",
reqd: 1,
},
{ fieldtype: "Section Break" },
{
label: __("Is Submittable"),
fieldname: "is_submittable",
fieldtype: "Check",
description: __(
"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended."
),
depends_on: "eval:!doc.istable && !doc.issingle",
},
{
label: __("Is Child Table"),
fieldname: "istable",
fieldtype: "Check",
description: __("Child Tables are shown as a Grid in other DocTypes"),
depends_on: "eval:!doc.is_submittable && !doc.issingle",
},
{
label: __("Editable Grid"),
fieldname: "editable_grid",
fieldtype: "Check",
depends_on: "istable",
default: 1,
},
{
label: __("Is Single"),
fieldname: "issingle",
fieldtype: "Check",
description: __(
"Single Types have only one record no tables associated. Values are stored in tabSingles"
),
depends_on: "eval:!doc.istable && !doc.is_submittable",
},
{
label: __("Custom?"),
fieldname: "custom",
fieldtype: "Check",
default: non_developer,
read_only: non_developer,
},
],
primary_action_label: __("Create & Continue"),
primary_action(values) {
if (!values.istable) values.editable_grid = 0;
frappe.db
.insert({
doctype: "DocType",
name: values.doctype_name,
module: values.module,
istable: values.istable,
editable_grid: values.editable_grid,
issingle: values.issingle,
custom: values.custom,
is_submittable: values.is_submittable,
permissions: [
{
create: 1,
delete: 1,
email: 1,
export: 1,
print: 1,
read: 1,
report: 1,
role: "System Manager",
share: 1,
write: 1,
},
],
fields: [
{
label: "Title",
fieldname: "title",
fieldtype: "Data",
},
],
})
.then((doc) => {
frappe.set_route("form-builder", doc.name);
});
},
secondary_action_label: __("Back"),
secondary_action() {
new_d.hide();
window.history.back();
},
});
new_d.show();
};

View file

@ -1,19 +0,0 @@
{
"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

@ -3,7 +3,7 @@ import Sidebar from "./components/Sidebar.vue"
import Tabs from "./components/Tabs.vue";
import { computed, onMounted, watch, ref } from "vue";
import { useStore } from "./store";
import { onClickOutside, useMagicKeys, whenever } from "@vueuse/core";
import { onClickOutside } from "@vueuse/core";
let store = useStore();
@ -14,19 +14,6 @@ let should_render = computed(() => {
let container = ref(null);
onClickOutside(container, () => store.form.selected_field = null);
// cmd/ctrl + s to save the form
const { meta_s, ctrl_s } = useMagicKeys();
whenever(() => meta_s.value || ctrl_s.value, () => {
if (store.dirty) {
store.save_changes();
}
});
function setup_change_doctype_dialog() {
store.page.$title_area.on("click", () => {
frappe.pages["form-builder"].select_doctype();
});
}
watch(
() => store.form.layout,
@ -34,10 +21,7 @@ watch(
{ deep: true }
);
onMounted(() => {
store.fetch();
setup_change_doctype_dialog();
});
onMounted(() => store.fetch());
</script>
<template>
@ -62,9 +46,8 @@ onMounted(() => {
<style lang="scss" scoped>
.form-builder-container {
margin-bottom: -60px;
margin: -12px -20px -5px;
display: flex;
gap: 20px;
&.resizing {
user-select: none;
@ -79,12 +62,20 @@ onMounted(() => {
flex: 1;
}
.form-sidebar,
.form-sidebar {
border-right: 1px solid var(--border-color);
border-bottom-left-radius: var(--border-radius);
}
.form-main {
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
background-color: var(--card-bg);
margin: 10px;
}
.form-sidebar,
.form-main {
:deep(.section-columns.has-one-column .field) {
input.form-control, .signature-field {
width: calc(50% - 19px);

View file

@ -67,7 +67,7 @@ function on_drag_end(evt) {
<style lang="scss" scoped>
.fields-container {
height: calc(100vh - 250px);
height: calc(100vh - 233px);
overflow-y: auto;
display: grid;
gap: 8px;

View file

@ -77,7 +77,7 @@ watch(
opacity: 0;
background-color: var(--bg-gray);
transition: opacity 0.2s ease;
z-index: 10;
z-index: 4;
cursor: col-resize;
&:hover, &.resizing {

View file

@ -20,7 +20,8 @@ function activate_tab(tab) {
nextTick(() => {
$(".tabs .tab.active")[0].scrollIntoView({
behavior: "smooth",
inline: "center"
inline: "center",
block: "nearest",
});
});
}
@ -280,6 +281,7 @@ function delete_tab(with_children) {
.tab-contents {
max-height: calc(100vh - 210px);
overflow-y: auto;
overflow-x: hidden;
border-radius: var(--border-radius);
min-height: 70px;

View file

@ -5,9 +5,10 @@ import FormBuilderComponent from "./FormBuilder.vue";
import { registerGlobalComponents } from "./globals.js";
class FormBuilder {
constructor({ wrapper, page, doctype, customize }) {
constructor({ wrapper, frm, doctype, customize }) {
this.$wrapper = $(wrapper);
this.page = page;
this.frm = frm;
this.page = frm.page;
this.doctype = doctype;
this.customize = customize;
this.read_only = false;
@ -21,21 +22,14 @@ class FormBuilder {
this.setup_page_actions();
!refresh && this.setup_app();
refresh && this.update_store();
this.watch_changes();
}
async setup_page_actions() {
// clear actions
this.page.clear_actions();
this.page.clear_menu();
this.page.clear_custom_actions();
// setup page actions
this.primary_btn = this.page.set_primary_action(__("Save"), () =>
this.store.save_changes()
);
setup_page_actions() {
this.preview_btn?.remove();
this.preview_btn = this.page.add_button(__("Show Preview"), () => {
this.store.frm.layout.tabs.find((tab) => tab.label === "Form").set_active();
this.store.preview = !this.store.preview;
if (this.store.read_only && !this.read_only) {
@ -44,34 +38,10 @@ class FormBuilder {
this.store.read_only = this.store.preview;
this.read_only = true;
});
this.reset_changes_btn = this.page.add_button(__("Reset Changes"), () => {
this.store.reset_changes();
// toggle preview btn text
this.preview_btn.text(this.store.preview ? __("Hide Preview") : __("Show Preview"));
});
this.go_to_doctype_list_btn = this.page.add_button(
__("Go to {0} List", [__(this.doctype)]),
() => {
window.open(`/app/${frappe.router.slug(this.doctype)}`);
}
);
this.customize_form_btn = this.page.add_menu_item(__("Switch to Customize"), () => {
frappe.set_route("form-builder", this.doctype, "customize");
});
this.doctype_form_btn = this.page.add_menu_item(__("Switch to DocType"), () => {
frappe.set_route("form-builder", this.doctype);
});
this.go_to_doctype_btn = this.page.add_menu_item(__("Go to DocType"), () =>
frappe.set_route("Form", "DocType", this.doctype)
);
this.go_to_customize_form_btn = this.page.add_menu_item(__("Go to Customize Form"), () =>
frappe.set_route("Form", "Customize Form", {
doc_type: this.doctype,
})
);
}
setup_app() {
@ -85,9 +55,7 @@ class FormBuilder {
// create a store
this.store = useStore();
this.store.doctype = this.doctype;
this.store.is_customize_form = this.customize;
this.store.page = this.page;
this.update_store();
// register global components
registerGlobalComponents(app);
@ -96,69 +64,21 @@ class FormBuilder {
this.$form_builder = app.mount(this.$wrapper.get(0));
}
update_store() {
this.store.doctype = this.doctype;
this.store.is_customize_form = this.customize;
this.store.page = this.page;
this.store.frm = this.frm;
}
watch_changes() {
watchEffect(() => {
if (this.store.dirty) {
this.page.set_indicator(__("Not Saved"), "orange");
this.reset_changes_btn.show();
if (this.store.dirty || this.frm.is_dirty()) {
this.frm.dirty();
} else {
this.page.clear_indicator();
this.reset_changes_btn.hide();
}
// hide all buttons
this.go_to_doctype_list_btn.hide();
this.customize_form_btn.hide();
this.doctype_form_btn.hide();
this.go_to_doctype_btn.hide();
this.go_to_customize_form_btn.hide();
this.page.menu_btn_group.show();
let hide_menu = true;
// show customize form & Go to customize form btn
if (
this.store.doc &&
!this.store.doc.custom &&
!this.store.doc.issingle &&
!this.store.is_customize_form &&
!in_list(frappe.model.core_doctypes_list, this.doctype)
) {
this.customize_form_btn.show();
this.go_to_customize_form_btn.show();
hide_menu = false;
}
// show doctype form & Go to doctype form btn
if (
this.store.doc &&
!this.store.doc.custom &&
!this.store.doc.issingle &&
this.store.is_customize_form
) {
this.doctype_form_btn.show();
this.go_to_doctype_btn.show();
hide_menu = false;
}
// show Go to {0} List or Go to {0} button
if (this.store.doc && !this.store.doc.istable) {
let label = this.store.doc.issingle
? __("Go to {0}", [__(this.doctype)])
: __("Go to {0} List", [__(this.doctype)]);
this.go_to_doctype_list_btn.text(label).show();
}
if (hide_menu && window.matchMedia("(min-device-width: 992px)").matches) {
this.page.menu_btn_group.hide();
}
// toggle preview btn text
this.preview_btn.text(this.store.preview ? __("Hide Preview") : __("Show Preview"));
// toggle primary btn and show indicator based on read_only state
this.primary_btn.toggle(!this.store.read_only);
if (this.store.read_only) {
let message = this.store.preview ? __("Preview Mode") : __("Read Only");
this.page.set_indicator(message, "orange");

View file

@ -5,6 +5,7 @@ import { useDebouncedRefHistory, onKeyDown } from "@vueuse/core";
export const useStore = defineStore("form-builder-store", () => {
let doctype = ref("");
let frm = ref(null);
let doc = ref(null);
let docfields = ref([]);
let custom_docfields = ref([]);
@ -69,17 +70,9 @@ export const useStore = defineStore("form-builder-store", () => {
}
async function fetch() {
await frappe.model.clear_doc("DocType", doctype.value);
await frappe.model.with_doctype(doctype.value);
if (is_customize_form.value) {
await frappe.model.with_doc("Customize Form");
let _doc = frappe.get_doc("Customize Form");
_doc.doc_type = doctype.value;
let r = await frappe.call({ method: "fetch_to_customize", doc: _doc });
doc.value = r.docs[0];
} else {
doc.value = await frappe.db.get_doc("DocType", doctype.value);
doc.value = frm.value.doc;
if (doctype.value.startsWith("new-doctype-")) {
doc.value.fields = [get_df("Data", "", __("Title"))];
}
if (!get_docfields.value.length) {
@ -99,18 +92,19 @@ export const useStore = defineStore("form-builder-store", () => {
nextTick(() => {
dirty.value = false;
frm.value.doc.__unsaved = 0;
frm.value.page.clear_indicator();
read_only.value =
!is_customize_form.value && !frappe.boot.developer_mode && !doc.value.custom;
preview.value = false;
});
setup_undo_redo();
setup_breadcrumbs();
}
let undo_redo_keyboard_event = onKeyDown(true, (e) => {
if (!ref_history.value) return;
if (e.ctrlKey || e.metaKey) {
if (frm.value.get_active_tab().label == "Form" && (e.ctrlKey || e.metaKey)) {
if (e.key === "z" && !e.shiftKey && ref_history.value.canUndo) {
ref_history.value.undo();
} else if (e.key === "z" && e.shiftKey && ref_history.value.canRedo) {
@ -125,30 +119,17 @@ export const useStore = defineStore("form-builder-store", () => {
undo_redo_keyboard_event;
}
function setup_breadcrumbs() {
!is_customize_form.value && frappe.model.init_doctype("DocType");
let breadcrumbs = `
<li><a href="/app/doctype">${__("DocType")}</a></li>
<li><a href="/app/doctype/${doctype.value}">${__(doctype.value)}</a></li>
`;
if (is_customize_form.value) {
breadcrumbs = `
<li><a href="/app/customize-form?doc_type=${doctype.value}">
${__("Customize Form")}
</a></li>
`;
}
breadcrumbs += `<li class="disabled"><a href="#">${__("Form Builder")}</a></li>`;
frappe.breadcrumbs.clear();
frappe.breadcrumbs.$breadcrumbs.append(breadcrumbs);
}
function reset_changes() {
fetch();
}
function validate_fields(fields, is_table) {
fields = scrub_field_names(fields);
let error_message = "";
let has_fields = fields.some((df) => {
return !["Section Break", "Tab Break", "Column Break"].includes(df.fieldtype);
});
if (!has_fields) {
error_message = __("DocType must have atleast one field");
}
let not_allowed_in_list_view = ["Attach Image", ...frappe.model.no_value_type];
if (is_table) {
@ -168,70 +149,56 @@ export const useStore = defineStore("form-builder-store", () => {
// check if fieldname already exist
let duplicate = fields.filter((f) => f.fieldname == df.fieldname);
if (duplicate.length > 1) {
frappe.throw(__("Fieldname {0} appears multiple times", get_field_data(df)));
error_message = __("Fieldname {0} appears multiple times", get_field_data(df));
}
// Link & Table fields should always have options set
if (in_list(["Link", ...frappe.model.table_fields], df.fieldtype) && !df.options) {
frappe.throw(
__("Options is required for field {0} of type {1}", get_field_data(df))
error_message = __(
"Options is required for field {0} of type {1}",
get_field_data(df)
);
}
// Do not allow if field is hidden & required but doesn't have default value
if (df.hidden && df.reqd && !df.default) {
frappe.throw(
__(
"{0} cannot be hidden and mandatory without any default value",
get_field_data(df)
)
error_message = __(
"{0} cannot be hidden and mandatory without any default value",
get_field_data(df)
);
}
// In List View is not allowed for some fieldtypes
if (df.in_list_view && in_list(not_allowed_in_list_view, df.fieldtype)) {
frappe.throw(
__(
"'In List View' is not allowed for field {0} of type {1}",
get_field_data(df)
)
error_message = __(
"'In List View' is not allowed for field {0} of type {1}",
get_field_data(df)
);
}
// In Global Search is not allowed for no_value_type fields
if (df.in_global_search && in_list(frappe.model.no_value_type, df.fieldtype)) {
frappe.throw(
__(
"'In Global Search' is not allowed for field {0} of type {1}",
get_field_data(df)
)
error_message = __(
"'In Global Search' is not allowed for field {0} of type {1}",
get_field_data(df)
);
}
});
return error_message;
}
async function save_changes() {
if (!dirty.value) {
frappe.show_alert({ message: __("No changes to save"), indicator: "orange" });
return;
}
function update_fields() {
if (!dirty.value && !frm.value.is_new()) return;
frappe.dom.freeze(__("Saving..."));
try {
if (is_customize_form.value) {
let _doc = frappe.get_doc("Customize Form");
_doc.doc_type = doctype.value;
_doc.fields = get_updated_fields();
validate_fields(_doc.fields, _doc.istable);
await frappe.call({ method: "save_customization", doc: _doc });
} else {
doc.value.fields = get_updated_fields();
validate_fields(doc.value.fields, doc.value.istable);
await frappe.call("frappe.client.save", { doc: doc.value });
frappe.toast("Fields Table Updated");
}
fetch();
let fields = get_updated_fields();
let has_error = validate_fields(fields, doc.value.istable);
if (has_error) return has_error;
doc.value.fields = fields;
return fields;
} catch (e) {
console.error(e);
} finally {
@ -242,6 +209,9 @@ export const useStore = defineStore("form-builder-store", () => {
function get_updated_fields() {
let fields = [];
let idx = 0;
let new_field_name = is_customize_form.value
? "new-customize-form-field-"
: "new-docfield-";
let layout_fields = JSON.parse(JSON.stringify(form.value.layout.tabs));
@ -252,6 +222,9 @@ export const useStore = defineStore("form-builder-store", () => {
) {
idx++;
tab.df.idx = idx;
if (tab.df.__unsaved && tab.df.__islocal) {
tab.df.name = new_field_name + idx;
}
fields.push(tab.df);
}
@ -265,6 +238,9 @@ export const useStore = defineStore("form-builder-store", () => {
if ((j == 0 && is_df_updated(section.df, get_df("Section Break"))) || j > 0) {
idx++;
section.df.idx = idx;
if (section.df.__unsaved && section.df.__islocal) {
section.df.name = new_field_name + idx;
}
fields.push(section.df);
}
@ -277,12 +253,18 @@ export const useStore = defineStore("form-builder-store", () => {
) {
idx++;
column.df.idx = idx;
if (column.df.__unsaved && column.df.__islocal) {
column.df.name = new_field_name + idx;
}
fields.push(column.df);
}
column.fields.forEach((field) => {
idx++;
field.df.idx = idx;
if (field.df.__unsaved && field.df.__islocal) {
field.df.name = new_field_name + idx;
}
fields.push(field.df);
section.has_fields = true;
});
@ -300,9 +282,11 @@ export const useStore = defineStore("form-builder-store", () => {
}
function is_df_updated(df, new_df) {
delete df.name;
delete new_df.name;
return JSON.stringify(df) != JSON.stringify(new_df);
let df_copy = JSON.parse(JSON.stringify(df));
let new_df_copy = JSON.parse(JSON.stringify(new_df));
delete df_copy.name;
delete new_df_copy.name;
return JSON.stringify(df_copy) != JSON.stringify(new_df_copy);
}
function get_layout() {
@ -311,6 +295,7 @@ export const useStore = defineStore("form-builder-store", () => {
return {
doctype,
frm,
doc,
form,
dirty,
@ -326,9 +311,8 @@ export const useStore = defineStore("form-builder-store", () => {
has_standard_field,
is_user_generated_field,
fetch,
reset_changes,
validate_fields,
save_changes,
update_fields,
get_updated_fields,
is_df_updated,
get_layout,

View file

@ -61,8 +61,6 @@ export function create_layout(fields) {
if (df.fieldname) {
// make a copy to avoid mutation bugs
df = JSON.parse(JSON.stringify(df));
} else {
continue;
}
if (df.fieldtype === "Tab Break") {
@ -71,7 +69,7 @@ export function create_layout(fields) {
set_section(df);
} else if (df.fieldtype === "Column Break") {
set_column(df);
} else if (df.name) {
} else {
if (!column) set_column();
let field = { df: df };

View file

@ -134,8 +134,6 @@ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form.
setTimeout(() => (this.frm.__from_autoname = false), 500);
}
this.frm.set_df_property("fields", "reqd", this.frm.doc.autoname !== "Prompt");
}
setup_fetch_from_fields(doc, doctype, docname) {

View file

@ -77,9 +77,12 @@ frappe.ui.form.Form = class FrappeForm {
// wrapper
this.wrapper = this.parent;
this.$wrapper = $(this.wrapper);
let is_single_column = this.doctype === "DocType" ? true : this.meta.hide_toolbar;
frappe.ui.make_app_page({
parent: this.wrapper,
single_column: this.meta.hide_toolbar,
single_column: is_single_column,
});
this.page = this.wrapper.page;
this.layout_main = this.page.main.get(0);
@ -157,12 +160,14 @@ frappe.ui.form.Form = class FrappeForm {
action: () => this.undo_manager.undo(),
page: this.page,
description: __("Undo last action"),
condition: () => !this.is_form_builder(),
});
frappe.ui.keys.add_shortcut({
shortcut: "shift+ctrl+z",
action: () => this.undo_manager.redo(),
page: this.page,
description: __("Redo last action"),
condition: () => !this.is_form_builder(),
});
frappe.ui.keys.add_shortcut({
shortcut: "ctrl+y",
@ -1361,6 +1366,13 @@ frappe.ui.form.Form = class FrappeForm {
return this.doc.__islocal;
}
is_form_builder() {
return (
in_list(["DocType", "Customize Form"], this.doctype) &&
this.get_active_tab().label == "Form"
);
}
get_perm(permlevel, access_type) {
return this.perm[permlevel] ? this.perm[permlevel][access_type] : null;
}

View file

@ -364,7 +364,10 @@ frappe.ui.form.Layout = class Layout {
const section = $(this).removeClass("empty-section visible-section");
if (section.find(".frappe-control:not(.hide-control)").length) {
section.addClass("visible-section");
} else {
} else if (
section.parent().hasClass("tab-pane") ||
section.parent().hasClass("form-page")
) {
// nothing visible, hide the section
section.addClass("empty-section");
}

View file

@ -1,12 +1,9 @@
<script setup>
import { ref, computed, nextTick } from "vue";
import { useStore } from "../store";
import { useVueFlow } from "@vue-flow/core";
let store = useStore();
let { nodes } = useVueFlow();
let title = ref("Workflow Details");
let doc = computed(() => {
@ -37,17 +34,6 @@ let properties = computed(() => {
df.options = ["Draft", "Submitted", "Cancelled"];
df.description = "";
}
if (df.fieldname == "state") {
let filter = nodes.value
.filter(state => state.type == "state")
.map(node => node.data.state);
if (doc.value.state) {
filter = filter.filter(state => state !== doc.value.state);
}
df.filters = {
name: ["not in", filter]
};
}
if (df.fieldname == "update_field") {
df.options = store.workflow_doc_fields;
}

View file

@ -582,7 +582,7 @@ def create_kanban():
@whitelist_for_tests
def create_todo(description):
frappe.get_doc({"doctype": "ToDo", "description": description}).insert()
return frappe.get_doc({"doctype": "ToDo", "description": description}).insert()
@whitelist_for_tests