diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js new file mode 100644 index 0000000000..d12be63f3b --- /dev/null +++ b/cypress/integration/form_tour.js @@ -0,0 +1,88 @@ +context('Form Tour', () => { + before(() => { + cy.login(); + cy.visit('/app/form-tour'); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_form_tour"); + }); + }); + + const open_test_form_tour = () => { + cy.visit('/app/form-tour/Test Form Tour'); + cy.get('button[data-label="Show%20Tour"]').should('be.visible').and('contain', 'Show Tour').as('show_tour'); + cy.get('@show_tour').click(); + cy.wait(500); + cy.url().should('include', '/app/contact'); + }; + + it('jump to a form tour', open_test_form_tour); + + it('navigates a form tour', () => { + open_test_form_tour(); + + cy.get('#driver-popover-item').should('be.visible'); + cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name'); + cy.get('@first_name').should('have.class', 'driver-highlighted-element'); + cy.get('.driver-next-btn').as('next_btn'); + + // next btn shouldn't move to next step, if first name is not entered + cy.get('@next_btn').click(); + cy.wait(500); + cy.get('@first_name').should('have.class', 'driver-highlighted-element'); + + // after filling the field, next step should be highlighted + cy.fill_field('first_name', 'Test Name', 'Data'); + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert field is highlighted + cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name'); + cy.get('@last_name').should('have.class', 'driver-highlighted-element'); + + // after filling the field, next step should be highlighted + cy.fill_field('last_name', 'Test Last Name', 'Data'); + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert field is highlighted + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos'); + cy.get('@phone_nos').should('have.class', 'driver-highlighted-element'); + + // move to next step + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert add row btn is highlighted + cy.get('@phone_nos').find('.grid-add-row').as('add_row'); + cy.get('@add_row').should('have.class', 'driver-highlighted-element'); + + // add a row & move to next step + cy.wait(500); + cy.get('@add_row').click(); + cy.wait(500); + + // assert table field is highlighted + cy.get('.grid-row-open .frappe-control[data-fieldname="phone"]').as('phone'); + cy.get('@phone').should('have.class', 'driver-highlighted-element'); + // enter value in a table field + cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + + // move to collapse row step + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // collapse row + cy.get('.grid-row-open .grid-collapse-row').click(); + cy.wait(500); + + // assert save btn is highlighted + cy.get('.primary-action').should('have.class', 'driver-highlighted-element'); + cy.get('@next_btn').should('contain', 'Save'); + + }); +}); + \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js index 94c6806b50..efb853cfa5 100644 --- a/frappe/desk/doctype/form_tour/form_tour.js +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -3,6 +3,45 @@ frappe.ui.form.on('Form Tour', { setup: function(frm) { + if (!frm.doc.is_standard || frappe.boot.developer_mode) { + frm.trigger('setup_queries'); + } + }, + + refresh(frm) { + if (frm.doc.is_standard && !frappe.boot.developer_mode) { + frm.trigger("disable_form"); + } + + frm.add_custom_button(__('Show Tour'), async () => { + const issingle = await check_if_single(frm.doc.reference_doctype); + + if (issingle) { + frappe.set_route('Form', frm.doc.reference_doctype); + } else { + const new_name = 'new-' + frappe.scrub(frm.doc.reference_doctype) + '-1'; + frappe.set_route('Form', frm.doc.reference_doctype, new_name); + } + frappe.utils.sleep(500).then(() => { + const tour_name = frm.doc.name; + cur_frm.tour + .init({ tour_name }) + .then(() => cur_frm.tour.start()); + }); + }); + }, + + disable_form: function(frm) { + frm.set_read_only(); + frm.fields + .filter((field) => field.has_input) + .forEach((field) => { + frm.set_df_property(field.df.fieldname, "read_only", "1"); + }); + frm.disable_save(); + }, + + setup_queries(frm) { frm.set_query("reference_doctype", function() { return { filters: { @@ -20,5 +59,65 @@ frappe.ui.form.on('Form Tour', { } }; }); + + frm.set_query("parent_field", "steps", function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: frm.doc.reference_doctype, + fieldtype: "Table", + hidden: 0, + } + }; + }); + + frm.trigger('reference_doctype'); + }, + + reference_doctype(frm) { + if (!frm.doc.reference_doctype) return; + + frappe.db.get_list('DocField', { + filters: { + parent: frm.doc.reference_doctype, + parenttype: 'DocType', + fieldtype: 'Table' + }, + fields: ['options'] + }).then(res => { + if (Array.isArray(res)) { + frm.child_doctypes = res.map(r => r.options); + } + }); + } }); + +frappe.ui.form.on('Form Tour Step', { + parent_field(frm, cdt, cdn) { + const child_row = locals[cdt][cdn]; + frappe.model.set_value(cdt, cdn, 'field', ''); + const field_control = get_child_field("steps", cdn, "field"); + field_control.get_query = function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: child_row.child_doctype, + hidden: 0 + } + }; + }; + } +}); + +function get_child_field(child_table, child_name, fieldname) { + // gets the field from grid row form + const grid = cur_frm.fields_dict[child_table].grid; + const grid_row = grid.grid_rows_by_docname[child_name]; + return grid_row.grid_form.fields_dict[fieldname]; +} + +async function check_if_single(doctype) { + const { message } = await frappe.db.get_value('DocType', doctype, 'issingle'); + return message.issingle || 0; +} \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json index 8e09a5d63a..e4ea528fcc 100644 --- a/frappe/desk/doctype/form_tour/form_tour.json +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -8,7 +8,9 @@ "field_order": [ "title", "reference_doctype", - "completed", + "module", + "is_standard", + "save_on_complete", "section_break_3", "steps" ], @@ -19,23 +21,16 @@ "in_list_view": 1, "label": "Reference Document", "options": "DocType", - "reqd": 1, - "unique": 1 + "reqd": 1 }, { + "depends_on": "reference_doctype", "fieldname": "steps", "fieldtype": "Table", "label": "Steps", "options": "Form Tour Step", "reqd": 1 }, - { - "default": "0", - "depends_on": "eval: doc.__islocal != 1", - "fieldname": "completed", - "fieldtype": "Check", - "label": "Mark as Completed" - }, { "fieldname": "section_break_3", "fieldtype": "Section Break" @@ -46,11 +41,32 @@ "label": "Title", "reqd": 1, "unique": 1 + }, + { + "default": "0", + "fieldname": "save_on_complete", + "fieldtype": "Check", + "label": "Save on Completion" + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" + }, + { + "fetch_from": "reference_doctype.module", + "fieldname": "module", + "fieldtype": "Link", + "hidden": 1, + "label": "Module", + "options": "Module Def", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-05-26 19:36:59.093753", + "modified": "2021-06-06 20:32:54.068774", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour", diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py index dd762395c4..dbc667ce28 100644 --- a/frappe/desk/doctype/form_tour/form_tour.py +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -3,9 +3,33 @@ import frappe from frappe.model.document import Document +from frappe.modules.export_file import export_to_files class FormTour(Document): - pass + def before_insert(self): + if not self.is_standard: + return + + # while syncing, set proper docfield reference + for d in self.steps: + if not frappe.db.exists('DocField', d.field): + d.field = frappe.db.get_value('DocField', { + 'fieldname': d.fieldname, 'parent': self.reference_doctype, 'fieldtype': d.fieldtype + }, "name") + + if d.is_table_field and not frappe.db.exists('DocField', d.parent_field): + d.parent_field = frappe.db.get_value('DocField', { + 'fieldname': d.parent_fieldname, 'parent': self.reference_doctype, 'fieldtype': 'Table' + }, "name") + + def on_update(self): + if frappe.conf.developer_mode and self.is_standard: + export_to_files([['Form Tour', self.name]], self.module) + + def before_export(self, doc): + for d in doc.steps: + d.field = "" + d.parent_field = "" @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -16,17 +40,23 @@ def get_docfield_list(doctype, txt, searchfield, start, page_len, filters): ['fieldtype', 'like', '%' + txt + '%'] ] - parent_doctype = filters.pop('doctype') - excluded_fieldtypes = ['Column Break'] - excluded_fieldtypes += filters.get('excluded_fieldtypes', []) + parent_doctype = filters.get('doctype') + fieldtype = filters.get('fieldtype') + if not fieldtype: + excluded_fieldtypes = ['Column Break'] + excluded_fieldtypes += filters.get('excluded_fieldtypes', []) + fieldtype_filter = ['not in', excluded_fieldtypes] + else: + fieldtype_filter = fieldtype docfields = frappe.get_all( doctype, fields=["name as value", "label", "fieldtype"], - filters={'parent': parent_doctype, 'fieldtype': ['not in', excluded_fieldtypes]}, + filters={'parent': parent_doctype, 'fieldtype': fieldtype_filter}, or_filters=or_filters, limit_start=start, limit_page_length=page_len, + order_by="idx", as_list=1, ) return docfields diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json index a772a2498a..3b6c91a208 100644 --- a/frappe/desk/doctype/form_tour_step/form_tour_step.json +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json @@ -4,14 +4,22 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "is_table_field", + "section_break_2", + "parent_field", "field", "title", "description", "column_break_2", "position", - "fieldname", "label", - "condition" + "has_next_condition", + "next_step_condition", + "section_break_13", + "fieldname", + "parent_fieldname", + "fieldtype", + "child_doctype" ], "fields": [ { @@ -30,6 +38,7 @@ "reqd": 1 }, { + "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_field))", "fieldname": "field", "fieldtype": "Link", "label": "Field", @@ -64,16 +73,73 @@ "options": "Left\nLeft Center\nLeft Bottom\nTop\nTop Center\nTop Right\nRight\nRight Center\nRight Bottom\nBottom\nBottom Center\nBottom Right\nMid Center" }, { + "depends_on": "has_next_condition", "fieldname": "next_step_condition", "fieldtype": "Code", "label": "Next Step Condition", + "oldfieldname": "condition", "options": "JS" + }, + { + "default": "0", + "fieldname": "has_next_condition", + "fieldtype": "Check", + "label": "Has Next Condition" + }, + { + "default": "0", + "fetch_from": "field.fieldtype", + "fieldname": "fieldtype", + "fieldtype": "Data", + "hidden": 1, + "label": "Fieldtype", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_table_field", + "fieldtype": "Check", + "label": "Is Table Field" + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "depends_on": "is_table_field", + "fieldname": "parent_field", + "fieldtype": "Link", + "label": "Parent Field", + "mandatory_depends_on": "is_table_field", + "options": "DocField" + }, + { + "fieldname": "section_break_13", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Hidden Fields" + }, + { + "fetch_from": "parent_field.options", + "fieldname": "child_doctype", + "fieldtype": "Data", + "hidden": 1, + "label": "Child Doctype", + "read_only": 1 + }, + { + "fetch_from": "parent_field.fieldname", + "fieldname": "parent_fieldname", + "fieldtype": "Data", + "hidden": 1, + "label": "Parent Fieldname", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-26 19:44:48.737453", + "modified": "2021-06-06 20:52:21.076972", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour Step", @@ -82,4 +148,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 28f9deb25d..836f70dd55 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -82,7 +82,7 @@ def get_doc_files(files, start_path): document_types = ['doctype', 'page', 'report', 'dashboard_chart_source', 'print_format', 'website_theme', 'web_form', 'web_template', 'notification', 'print_style', 'data_migration_mapping', 'data_migration_plan', 'workspace', - 'onboarding_step', 'module_onboarding'] + 'onboarding_step', 'module_onboarding', 'form_tour'] for doctype in document_types: doctype_path = os.path.join(start_path, doctype) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 46812f5fb6..65c0139b65 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -607,9 +607,7 @@ frappe.Application = class Application { let doc = JSON.parse(pasted_data); if (doc.doctype) { e.preventDefault(); - let sleep = (time) => { - return new Promise((resolve) => setTimeout(resolve, time)); - }; + const sleep = frappe.utils.sleep; frappe.dom.freeze(__('Creating {0}', [doc.doctype]) + '...'); // to avoid abrupt UX diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index eb7a6edc5d..6833f68073 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -179,7 +179,7 @@ frappe.ui.form.Dashboard = class FormDashboard { return; } this.render_links(); - this.set_open_count(); + // this.set_open_count(); show = true; } diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index a24c6ab0d6..6ddbd089fa 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -12,6 +12,7 @@ import './script_manager'; import './script_helpers'; import './sidebar/form_sidebar'; import './footer/footer'; +import './form_tour'; frappe.ui.form.Controller = class FormController { constructor(opts) { @@ -152,6 +153,10 @@ frappe.ui.form.Form = class FrappeForm { parent: $('