Merge pull request #13528 from nextchamp-saqib/form-tour-for-grids

feat: Form Tour for Grids
This commit is contained in:
mergify[bot] 2021-06-24 07:50:08 +00:00 committed by GitHub
commit 518c82fc82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 658 additions and 78 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -179,7 +179,7 @@ frappe.ui.form.Dashboard = class FormDashboard {
return;
}
this.render_links();
this.set_open_count();
// this.set_open_count();
show = true;
}

View file

@ -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: $('<div class="form-dashboard">').insertAfter(this.layout.wrapper.find('.form-message'))
});
this.tour = new frappe.ui.form.FormTour({
frm: this
});
// workflow state
this.states = new frappe.ui.form.States({
frm: this
@ -1606,53 +1611,6 @@ frappe.ui.form.Form = class FrappeForm {
}, 1000);
}
show_tour(on_finish) {
const tour_info = frappe.tour[this.doctype];
if (!Array.isArray(tour_info)) {
return;
}
const driver = new frappe.Driver({
className: 'frappe-driver',
allowClose: false,
padding: 10,
overlayClickNext: true,
keyboardControl: true,
nextBtnText: 'Next',
prevBtnText: 'Previous',
opacity: 0.25
});
this.layout.sections.forEach(section => section.collapse(false));
let steps = tour_info.map(step => {
let field = this.get_docfield(step.fieldname);
return {
element: `.frappe-control[data-fieldname='${step.fieldname}']`,
popover: {
title: step.title || field.label,
description: step.description,
position: step.position || 'bottom'
},
onNext: () => {
const next_condition_satisfied = this.layout.evaluate_depends_on_value(step.next_step_condition || true);
if (!next_condition_satisfied) {
driver.preventMove();
}
if (!driver.hasNextStep()) {
on_finish && on_finish();
}
}
};
});
driver.defineSteps(steps);
frappe.router.on('change', () => driver.reset());
driver.start();
}
setup_docinfo_change_listener() {
let doctype = this.doctype;
let docname = this.docname;

View file

@ -0,0 +1,252 @@
frappe.ui.form.FormTour = class FormTour {
constructor({ frm }) {
this.frm = frm;
this.driver_steps = [];
this.init_driver();
}
init_driver() {
this.driver = new frappe.Driver({
className: 'frappe-driver',
allowClose: false,
padding: 10,
overlayClickNext: true,
keyboardControl: true,
nextBtnText: 'Next',
prevBtnText: 'Previous',
opacity: 0.25,
onHighlighted: (step) => {
// if last step is to save, then attach a listener to save button
if (step.options.is_save_step) {
$(step.options.element).one('click', () => this.driver.reset());
}
// focus on input
const $input = $(step.node).find('input').get(0);
if ($input)
frappe.utils.sleep(200).then(() => $input.focus());
}
});
frappe.router.on('change', () => this.driver.reset());
this.frm.layout.sections.forEach(section => section.collapse(false));
}
async init({ tour_name, on_finish }) {
if (tour_name) {
this.tour = await frappe.db.get_doc('Form Tour', tour_name);
} else {
this.tour = { steps: frappe.tour[this.frm.doctype] };
}
if (on_finish) this.on_finish = on_finish;
this.build_steps();
this.update_driver_steps();
}
build_steps() {
this.driver_steps = [];
this.tour.steps.forEach((step) => {
const on_next = () => {
if (!this.is_next_condition_satisfied(step)) {
this.driver.preventMove();
}
if (!this.driver.hasNextStep()) {
this.on_finish && this.on_finish();
}
};
const driver_step = this.get_step(step, on_next);
this.driver_steps.push(driver_step);
if (step.fieldtype == 'Table') this.handle_table_step(step);
if (step.is_table_field) this.handle_child_table_step(step);
});
if (this.tour.save_on_complete) {
this.add_step_to_save();
}
}
is_next_condition_satisfied(step) {
const form = step.is_table_field ? this.frm.cur_grid.grid_form : this.frm;
return form.layout.evaluate_depends_on_value(step.next_step_condition || true);
}
get_step(step_info, on_next) {
const { name, fieldname, title, description, position, is_table_field } = step_info;
const field = this.frm.get_field(fieldname);
let element = field ? field.wrapper : `.frappe-control[data-fieldname='${fieldname}']`;
if (is_table_field) {
element = `.grid-row-open .frappe-control[data-fieldname='${fieldname}']`;
}
return {
element,
name,
popover: { title, description, position: frappe.router.slug(position) },
onNext: on_next
};
}
update_driver_steps(steps = []) {
if (steps.length == 0) {
steps = this.driver_steps;
}
this.driver.defineSteps(steps);
}
start(idx = 0) {
if (this.driver_steps.length == 0) {
return;
}
this.driver.start(idx);
}
get_next_step() {
// returns the next step only if driver is active
if (this.driver.isActivated & this.driver.hasNextStep()) {
const current_step = this.driver.currentStep;
return this.driver.steps[current_step + 1];
}
return;
}
handle_table_step(step_info) {
const is_last_step = step_info.idx == this.tour.steps.length;
if (!is_last_step) {
// if next step field is inside currently highlighted table field
// then check if there is a row -> if not, then prompt to add row
// then edit the first row and hightlight next step
const curr_step = step_info;
const next_step = this.tour.steps[curr_step.idx];
const is_next_field_in_curr_table = next_step.parent_field == curr_step.field;
if (!is_next_field_in_curr_table) return;
const rows = this.frm.doc[curr_step.fieldname];
const table_has_rows = rows && rows.length > 0;
if (table_has_rows) {
// table already has rows
// then just edit the first one on next step
const curr_driver_step = this.driver_steps.find(s => s.name == curr_step.name);
curr_driver_step.onNext = () => {
if (this.is_next_condition_satisfied(curr_step)) {
this.expand_row_and_proceed(curr_step, curr_step.idx);
} else {
this.driver.preventMove();
}
};
this.update_driver_steps();
} else {
this.add_new_row_step(curr_step);
}
}
}
add_new_row_step(step) {
const $add_row = `.frappe-control[data-fieldname='${step.fieldname}'] .grid-add-row`;
const add_row_step = {
element: $add_row,
popover: { title: __("Add a Row"), description: "" },
onNext: () => {
if (!cur_frm.cur_grid) {
this.driver.preventMove();
}
}
};
this.driver_steps.push(add_row_step);
// setup a listener on add row button
// so, once the row is added, move to next step automatically
$($add_row).one('click', () => {
this.expand_row_and_proceed(step, step.idx + 1); // +1 since add row step is added
});
}
expand_row_and_proceed(step, start_from) {
this.open_first_row_of(step.fieldname);
this.update_driver_steps(); // need to define again, since driver.js only considers steps which are inside DOM
frappe.utils.sleep(300).then(() => this.driver.start(start_from));
}
open_first_row_of(fieldname) {
this.frm.fields_dict[fieldname].grid.grid_rows[0].toggle_view();
// setup a listener on close row button
// so, once the row is closed, move to next step automatically
const $close_row = '.grid-row-open .grid-collapse-row';
$($close_row).one('click', () => {
const next_step = this.get_next_step();
const next_element = next_step.options.is_save_step ? null : next_step.node;
frappe.utils.scroll_to(next_element, true, 150, null, () => {
this.driver.moveNext();
frappe.flags.disable_auto_scroll = false;
});
frappe.flags.disable_auto_scroll = true;
});
}
handle_child_table_step(step_info) {
const is_last_step = step_info.idx == this.tour.steps.length;
if (!is_last_step) {
const curr_step = step_info;
const next_step = this.tour.steps[curr_step.idx];
const field = this.frm.get_field(next_step.fieldname);
if (!field) return;
// next step highlights parent field
// so, add a step to prompt user to collapse grid form
this.add_collapse_row_step();
} else if (this.tour.save_on_complete) {
// if last step & save on complete is checked
// add a step to prompt user to collapse grid form
// to be able to save as a last step
this.add_collapse_row_step();
}
}
add_collapse_row_step() {
const $close_row = '.grid-row-open .grid-collapse-row';
const close_row_step = {
element: $close_row,
popover: { title: __("Collapse"), description: "", position: "left" },
onNext: () => {
if (cur_frm.cur_grid) {
this.driver.preventMove();
}
}
};
this.driver_steps.push(close_row_step);
}
add_step_to_save() {
const page_id = `#page-${this.frm.doctype}`;
const $save_btn = `${page_id} .standard-actions .primary-action`;
const save_step = {
element: $save_btn,
is_save_step: true,
allowClose: false,
overlayClickNext: false,
popover: {
title: __("Save"),
description: "",
position: "left",
doneBtnText: __("Save")
}
};
this.driver_steps.push(save_step);
frappe.ui.form.on(this.frm.doctype, 'after_save', () => this.on_finish && this.on_finish());
}
};

View file

@ -268,7 +268,9 @@ Object.assign(frappe.utils, {
</a></p>');
return content.html();
},
scroll_to: function(element, animate=true, additional_offset, element_to_be_scrolled) {
scroll_to: function(element, animate=true, additional_offset, element_to_be_scrolled, callback) {
if (frappe.flags.disable_auto_scroll) return;
element_to_be_scrolled = element_to_be_scrolled || $("html, body");
let scroll_top = 0;
if (element) {
@ -289,7 +291,7 @@ Object.assign(frappe.utils, {
}
if (animate) {
element_to_be_scrolled.animate({ scrollTop: scroll_top });
element_to_be_scrolled.animate({ scrollTop: scroll_top }).promise().then(callback);
} else {
element_to_be_scrolled.scrollTop(scroll_top);
}
@ -1319,5 +1321,9 @@ Object.assign(frappe.utils, {
let e = clipboard_paste_event;
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
return clipboard_data.getData('Text');
},
sleep(time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
});

View file

@ -203,7 +203,7 @@ export default class OnboardingWidget extends Widget {
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
frm.show_tour(() => {
const on_finish = () => {
let msg_dialog = frappe.msgprint({
message: __("Let's take you back to onboarding"),
title: __("Great Job"),
@ -217,7 +217,10 @@ export default class OnboardingWidget extends Widget {
label: () => __("Continue"),
},
});
});
};
frm.tour
.init({ on_finish })
.then(() => frm.tour.start());
};
frappe.set_route(route);
@ -290,12 +293,15 @@ export default class OnboardingWidget extends Widget {
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
frm.show_tour(() => {
const on_finish = () => {
frappe.msgprint({
message: __("Awesome, now try making an entry yourself"),
title: __("Great"),
});
});
};
frm.tour
.init({ on_finish })
.then(() => frm.tour.start());
};
let callback = () => {

View file

@ -64,4 +64,16 @@ div#driver-popover-item {
input.driver-highlighted-element {
background-color: var(--fg-color);
}
.driver-fix-stacking {
z-index: auto !important;
position: unset !important;
opacity: 1.0 !important;
transform: none !important;
filter: none !important;
perspective: none !important;
transform-style: flat !important;
transform-box: border-box !important;
will-change: unset !important;
}

View file

@ -131,3 +131,52 @@ def insert_contact(first_name, phone_number):
})
doc.append('phone_nos', {'phone': phone_number})
doc.insert()
@frappe.whitelist()
def create_form_tour():
if frappe.db.exists('Form Tour', {'name': 'Test Form Tour'}):
return
def get_docfield_name(filters):
return frappe.db.get_value('DocField', filters, "name")
tour = frappe.get_doc({
'doctype': 'Form Tour',
'title': 'Test Form Tour',
'reference_doctype': 'Contact',
'save_on_complete': 1,
'steps': [{
"title": "Test Title 1",
"description": "Test Description 1",
"has_next_condition": 1,
"next_step_condition": "eval: doc.first_name",
"field": get_docfield_name({'parent': 'Contact', 'fieldname': 'first_name'}),
"fieldname": "first_name",
"fieldtype": "Data"
},{
"title": "Test Title 2",
"description": "Test Description 2",
"has_next_condition": 1,
"next_step_condition": "eval: doc.last_name",
"field": get_docfield_name({'parent': 'Contact', 'fieldname': 'last_name'}),
"fieldname": "last_name",
"fieldtype": "Data"
},{
"title": "Test Title 3",
"description": "Test Description 3",
"field": get_docfield_name({'parent': 'Contact', 'fieldname': 'phone_nos'}),
"fieldname": "phone_nos",
"fieldtype": "Table"
},{
"title": "Test Title 4",
"description": "Test Description 4",
"is_table_field": 1,
"parent_field": get_docfield_name({'parent': 'Contact', 'fieldname': 'phone_nos'}),
"field": get_docfield_name({'parent': 'Contact Phone', 'fieldname': 'phone'}),
"next_step_condition": "eval: doc.phone",
"has_next_condition": 1,
"fieldname": "phone",
"fieldtype": "Data"
}]
})
tour.insert()