
Add button to Child Table in Frappe
Add button to Child Table in Frappe
How to Add a Clickable Button
to a Frappe Child Table
Yes, this is a 6 page blog about adding a button to a table. On the surface it looks simple, but in Frappe child tables it touches formatters, edit layers, event handling, and row-level data access in ways that are easy to miss.
Published: April 2026 · By Auriga IT
What This Blog Covers
This blog covers the full process of rendering a clickable button inside a Frappe child table reliably. That means not just making it appear once, but keeping it visible during edits, preventing Frappe from hijacking the click, and identifying which row the user clicked.
The Roadmap
What’s the Problem in Adding a Simple Button?
At first glance, it sounds trivial: add a button field to the child table and show it in list view. But child tables are one of those places where Frappe’s internals become very visible.
value and df. It does not get the full child row, so row-level behavior needs a different approach.How Child Tables Work Under the Hood
Why Frappe Doesn’t Render Button Fields in Child Tables
It is effectively hardcoded. Frappe skips button field rendering in child table list view, so we need to use a different field type and control the cell output ourselves.
The Formatter System
A formatter is a function that receives a value and returns HTML. When Frappe renders a child table cell, it passes the stored value through that formatter. If you want a “button”, the formatter is what will generate the HTML for it.
frappe.meta.docfield_map['Stock Entry Detail']['your_field'].formatter = function(value, df) {
return `Click Me`;
}
The Two Layers
Each child table cell has two stacked layers. The static-area is the display layer and the field-area is the edit layer. Your formatter only affects the display side. When a row enters edit mode, Frappe hides the display layer and shows the edit layer, which is why the button seems to vanish.
The Click System
Frappe attaches click listeners to child table cells and rows. If your click reaches Frappe’s listener, it will open the row for editing. The reliable fix is to register your own listener in the capture phase and stop the event before Frappe handles it in the bubble phase.
How to Know Which Row Was Clicked
The formatter does not receive the row doc. Instead, you can read the row’s CDN from the DOM. Frappe renders child rows with a data-name attribute, and that value is the child docname you can use to fetch the row from locals.
const gridRowEl = btn.closest('.grid-row');
const cdn = gridRowEl.dataset.name;
Implementing It
For this example, we’ll add a button to YOUR_CHILD_DOCTYPE that opens a popup showing the item name of that row.
Step 1 — Add a Data Field to the Child Table
Add a Data field, not a Button field. Mark it as In List View so it shows up as a child table column. We’ll override its visual output using the formatter.
Step 2 — Override the Global Formatter
Add the formatter override in the JS file of the parent DocType and call it in both setup and refresh.
function apply_button_formatter() {
const map = frappe.meta.docfield_map['YOUR_CHILD_DOCTYPE'];
if (!map) return;
map['your_field_name'].formatter = function(value, df) {
return `
Click Me
`;
};
}
frappe.ui.form.on('YOUR_DOCTYPE', {
setup(frm) {
apply_button_formatter();
},
refresh(frm) {
apply_button_formatter();
}
});
setup runs once on load, while refresh re-applies after saves because Frappe can reset the meta.
Step 3 — Keep the Button Visible in Edit Mode
Force the display layer to stay visible for your pseudo-button column and hide the edit layer for that column.
.grid-static-col[data-fieldname="your_field_name"] .static-area {
display: flex !important;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.grid-static-col[data-fieldname="your_field_name"] .field-area {
display: none !important;
}
Step 4 — Intercept the Click Before Frappe Does
Attach a capture-phase listener to the form wrapper. This lets your logic run before Frappe’s own click handling opens the row.
function setup_button_handler(frm) {
if (frm._my_btn_handler) {
frm.wrapper.removeEventListener('click', frm._my_btn_handler, true);
}
frm._my_btn_handler = function(e) {
const btn = e.target.closest('.my-action-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const gridRow = btn.closest('.grid-row');
const cdn = gridRow && gridRow.dataset.name;
if (cdn) {
handle_button_click(cdn);
}
};
frm.wrapper.addEventListener('click', frm._my_btn_handler, true);
}
stopImmediatePropagation() prevents every other click listener from seeing the event.
Step 5 — Add the Action Function
For basic row reads, you do not need an API call. Frappe already stores loaded child rows in locals.
function handle_button_click(cdn) {
const row = locals['YOUR_CHILD_DOCTYPE'][cdn];
if (!row) return;
frappe.msgprint(`Item: ${row.item_code}`);
}
Let Claude Code Do It
Save the prompt below as child_table_button.md in your project root. Then open Claude Code and run:
Read ./child_table_button.md and follow the instructions in it
Claude will ask you for the DocType names, button label, function name, and the behavior you want. Then it can place the code in the right JS, CSS, and hook files.
The Prompt
You are setting up a clickable button inside a Frappe child table. This is a custom app project (not a client script).
Frappe does not render Button fields in child tables — they render as empty cells. The workaround is to add a Data field, override its formatter to render button HTML, add CSS to keep it visible in edit mode, and intercept clicks using capture-phase event listeners.
## Step 1 — Gather info
Ask the user for the following, one message at a time:
1. What is the parent DocType? (e.g. Stock Entry)
2. What is the child DocType? (e.g. Stock Entry Detail)
3. What should the button label say? (e.g. "Check Stock", "Assign Color")
4. What function should be called when the button is clicked? Just the name. (e.g. check_stock, assign_color)
5. Briefly, what should the function do? (e.g. "call a server API to check warehouse stock", "open a dialog to pick a color", "show item details in a popup")
## Step 2 — Tell the user to create the field
Based on the info gathered, tell the user:
"Go to Customize Form > [child DocType]. Add a Data field. For the field name, use [child_doctype_prefix]_[button_name] — prefixing with the doctype name prevents conflicts since the CSS for this is global. Mark it as 'In List View'. Set the label to whatever you want the column header to say. Save, then come back here."
Wait for confirmation before proceeding.
Ask the user to confirm the exact field name they used.
## Step 3 - Generate and place the code
Find the correct files in the app:
- JS: Look for an existing .js file for the parent DocType.
- CSS: The app's main CSS file, usually [app]/[app]/public/css/[app].css
If the JS file already has a frappe.ui.form.on('[Parent DocType]', { ... }) block, merge into it.
If no JS file exists for this doctype, create one and add the appropriate doctype_js entry in hooks.py.
If the CSS file doesn't exist, create it and ensure it's listed in app_include_css in hooks.py.
Generate the following code, substituting all placeholders:
### JS file content:
function apply_[function_name]_formatter() {
const map = frappe.meta.docfield_map['[CHILD_DOCTYPE]'];
if (!map) return;
map['[field_name]'].formatter = function(value, df) {
return `
[BUTTON_LABEL]
`;
};
}
function setup_[function_name]_handler(frm) {
if (frm._[function_name]_handler) {
frm.wrapper.removeEventListener('click', frm._[function_name]_handler, true);
}
frm._[function_name]_handler = function(e) {
const btn = e.target.closest('.[function_name]-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const gridRow = btn.closest('.grid-row');
const cdn = gridRow && gridRow.dataset.name;
if (cdn) {
[function_name](frm, cdn);
}
};
frm.wrapper.addEventListener('click', frm._[function_name]_handler, true);
}
function [function_name](frm, cdn) {
// ROW DATA ACCESS - see note below
}
frappe.ui.form.on('[PARENT_DOCTYPE]', {
setup(frm) {
apply_[function_name]_formatter();
setup_[function_name]_handler(frm);
},
refresh(frm) {
apply_[function_name]_formatter();
setup_[function_name]_handler(frm);
}
});
### CSS file content:
.grid-static-col[data-fieldname="[field_name]"] .static-area {
display: flex !important;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.grid-static-col[data-fieldname="[field_name]"] .field-area {
display: none !important;
}
## Step 4 - Fill in the action function
Based on what the user said the function should do, generate the correct function body.
Pattern A - Frontend only:
const row = locals['[CHILD_DOCTYPE]'][cdn];
Pattern B - Server call:
frappe.call({
method: '[app].[module].api.[method_name]',
args: { cdn: cdn },
callback: function(r) {
frm.reload_doc();
}
});
Pattern C - Both:
const row = locals['[CHILD_DOCTYPE]'][cdn];
frappe.call({ ... });
## Step 5 - Remind the user
After writing all code, tell the user:
- Run bench build and reload the page
- The CSS is global
- If they need a second button, run the prompt again with a different function name and field name
Questions About This Setup
data-name value from the parent .grid-row element. That gives you the CDN, which you can use to fetch the child row from locals or from the server.locals['CHILD_DOCTYPE'][cdn] is enough. Use an API call only when the action must run server-side.Want More Practical Frappe Engineering Breakdowns?
Use this layout for internal engineering blogs, product notes, and implementation guides that need technical depth without looking dry or generic.
Explore More Guides →Related content
Auriga: Leveling Up for Enterprise Growth!
Auriga’s journey began in 2010 crafting products for India’s [...]






