
Add button to Child Table in Frappe
Add button to Child Table in Frappe
To add a clickable button to a Frappe child table: add a Data field (not a Button field) marked as In List View, override its formatter to render button HTML, add CSS to keep it visible in edit mode, and intercept clicks using a capture-phase event listener before Frappe opens the row. Read the clicked row via data-name on the .grid-row element.
Yes, this is a six-part guide about adding a button to a table. On the surface it sounds trivial. In a Frappe child table, it touches formatters, edit layers, event handling, and row-level data access in ways that are very easy to miss.
This guide covers the full implementation — not just making the button appear once, but keeping it visible during edits, preventing Frappe from hijacking the click, and correctly identifying which row the user clicked.
If you want to skip the explanation and go straight to automation, jump to Part 5 and use the Claude Code prompt to generate the full setup for your specific DocType.
What This Guide Covers
This guide covers the complete process of rendering a clickable button inside a Frappe child table that works reliably — across page loads, saves, and edit cycles.
Why a Normal Button Field Fails
Everything that blocks a standard Button field from working in child table list view.
How Frappe Renders Child Tables
How formatters, render layers, click events, and row IDs actually work under the hood.
Step-by-Step Implementation
The exact JavaScript and CSS to set up — field, formatter, CSS fix, click handler, and action function.
Claude Code Prompt
A full prompt that generates the complete implementation for your specific DocType and use case.
Assumption: This guide assumes you already understand basic Frappe customisation — adding fields, reading data, and writing client scripts.
Part 1 — Why Adding a Simple Button Is Not Simple
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, very quickly.
A Button Field Won't Render
In child table list view, a Button field renders as an empty cell. The normal approach fails immediately.
Formatter Overrides Don't Stick
Even if you override the formatter, Frappe can overwrite it on save, refresh, and edit cycles.
Button Disappears in Edit Mode
A child table cell has separate display and edit layers. Your formatter only affects the display layer.
Clicks Open the Row Instead
Frappe already listens for clicks on child table cells. Clicking your button triggers row edit unless you intercept it first.
The Formatter Doesn't Get the Row Doc
The formatter only receives value and df. It does not receive the full child row, so row-level behaviour needs a different approach entirely.
Part 2 — How Frappe 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 you need to use a different field type and control the cell output manually through the formatter system.
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 generates its HTML.
Important: Override the global docfield_map entry, not a local copy. Frappe reuses the global meta while rendering — a local copy will not survive refresh cycles.
frappe.meta.docfield_map['Stock Entry Detail']['your_field'].formatter = function(value, df) {
return `Click Me`;
}
The Two Render 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 CSS fix in Step 3 of the implementation handles this.
The Click System
Frappe attaches click listeners to child table cells and rows. If your click reaches Frappe's listener, it opens 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, 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;
Part 3 — Step-by-Step Implementation
For this example, we add a button to YOUR_CHILD_DOCTYPE that opens a popup showing the item name of that row. Replace all placeholders with your actual DocType and field names.
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 appears as a column. We control its visual output using the formatter, not the field type.
Naming tip: Prefix the field name with your child DocType name (e.g. sed_check_stock) to avoid global CSS conflicts since the CSS we write will apply site-wide.
Step 2 — Override the Global Formatter
Add the formatter override in the JavaScript file of the parent DocType. Call it in both setup and refresh — setup runs once on load, refresh re-applies after saves when Frappe can reset the meta.
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();
}
});
Step 3 — Keep the Button Visible in Edit Mode
Force the display layer to stay visible for your column and hide the edit layer. Without this, the button vanishes the moment the row enters edit mode.
.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;
}
Important: This CSS is global — it applies to every form on the site. Using a prefixed field name (step 1) prevents accidental side effects on other child tables.
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 click handling opens the row. Calling stopImmediatePropagation() prevents every other listener from seeing the event.
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);
}
Step 5 — Add the Action Function
For basic row reads, no API call is needed. Frappe already stores loaded child rows in locals. For server-side operations, pass the CDN to a whitelisted method instead.
function handle_button_click(cdn) {
const row = locals['YOUR_CHILD_DOCTYPE'][cdn];
if (!row) return;
frappe.msgprint(`Item: ${row.item_code}`);
}
function handle_button_click(frm, cdn) {
frappe.call({
method: 'your_app.your_module.api.your_method',
args: { cdn: cdn },
callback: function(r) {
frm.reload_doc();
}
});
}
Part 4 — Complete Code Reference
Here is the full JavaScript file putting all steps together — formatter, handler setup, and action function in one place.
// Step 1: Override formatter so the button renders in the cell
function apply_button_formatter() {
const map = frappe.meta.docfield_map['YOUR_CHILD_DOCTYPE'];
if (!map || !map['your_field_name']) return;
map['your_field_name'].formatter = function(value, df) {
return `Click Me`;
};
}
// Step 2: Intercept clicks in capture phase before Frappe 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(frm, cdn);
};
frm.wrapper.addEventListener('click', frm._my_btn_handler, true);
}
// Step 3: Action to run when button is clicked
function handle_button_click(frm, cdn) {
const row = locals['YOUR_CHILD_DOCTYPE'][cdn];
if (!row) return;
frappe.msgprint({
title: 'Row Details',
message: `Item: ${row.item_code}`,
indicator: 'green'
});
}
// Step 4: Hook into form lifecycle
frappe.ui.form.on('YOUR_DOCTYPE', {
setup(frm) {
apply_button_formatter();
setup_button_handler(frm);
},
refresh(frm) {
apply_button_formatter();
setup_button_handler(frm);
}
});
/* Keep button visible when row is in edit mode */
.grid-static-col[data-fieldname="your_field_name"] .static-area {
display: flex !important;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
/* Hide the edit input for this column — the button handles all interaction */
.grid-static-col[data-fieldname="your_field_name"] .field-area {
display: none !important;
}
Part 5 — 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 for your DocType names, button label, function name, and desired behaviour — then place the generated code in the correct JS, CSS, and hook files automatically.
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 show as empty
cells. The workaround is:
1. Add a Data field and override its formatter to render button HTML
2. Add CSS to keep it visible in edit mode
3. Intercept clicks using a capture-phase event listener
## 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")
4. What function should run when clicked? (e.g. check_stock)
5. What should the function do? (e.g. "call a server API to check stock")
## Step 2 — Field setup instructions
Tell the user:
"Go to Customize Form > [child DocType]. Add a Data field.
Name it [child_doctype_prefix]_[button_name] to avoid CSS conflicts.
Mark it as In List View. Save, then confirm the exact field name here."
## Step 3 — Generate and place the code
Find the correct files in the app:
- JS: existing .js file for the parent DocType
- CSS: app's main CSS file
If the JS file has a frappe.ui.form.on block, merge into it.
If no JS file exists, create one and add to hooks.py.
If no CSS file exists, create one and add to app_include_css in hooks.py.
Generate this code with all placeholders replaced:
### JS:
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 cdn = btn.closest('.grid-row')?.dataset.name;
if (cdn) [function_name](frm, cdn);
};
frm.wrapper.addEventListener('click', frm._[function_name]_handler, true);
}
function [function_name](frm, cdn) {
// Implementation based on user's description
}
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:
.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 — Action function
Based on user's description, generate the function body:
- Frontend only: const row = locals['[CHILD_DOCTYPE]'][cdn];
- Server call: frappe.call({ method: '...', args: { cdn }, callback })
- Both patterns combined if needed
## Step 5 — Remind the user
After writing all code, tell the user:
- Run bench build and reload the page
- The CSS is global — use prefixed field names
- For a second button, run this prompt again with a different function name
Frequently Asked Questions
Why can't I just add a Button field to the Frappe child table?
Because Frappe does not render Button fields in child table list view — they show up as empty cells. The workaround is to add a Data field and override its formatter to render button HTML, then handle clicks with a capture-phase event listener.
Why does the Frappe child table button disappear when I click the row?
Because the formatter only affects the display layer (static-area). When the row enters edit mode, Frappe hides that layer and shows the edit layer (field-area). The CSS fix — setting the static-area to display:flex !important and hiding the field-area for your column — keeps the button visible at all times.
Why use the capture phase for the click handler?
Frappe's row click logic runs later in the bubble phase. A capture-phase listener (the third argument set to true in addEventListener) lets your code intercept and stop the event before Frappe can open the row for editing.
How do I know which child row was clicked?
Read the data-name attribute from the parent .grid-row element using btn.closest('.grid-row').dataset.name. That gives you the CDN (child docname), which you can use to fetch the row from locals or pass to a server-side method.
Do I always need an API call for the button action?
No. If you only need values from the already loaded child row, locals['CHILD_DOCTYPE'][cdn] is enough — no network request required. Use a server-side API call only when the action must run on the server, such as creating a document or querying data not already in the browser.
Why do I need to call apply_button_formatter in both setup and refresh?
setup runs once when the form first loads. refresh fires after every save and form reload. Frappe can reset the global meta during these cycles, which would remove your formatter override. Calling it in both handlers ensures the formatter is always applied.
Can I add multiple buttons to the same Frappe child table?
Yes. Add a separate Data field for each button, use a unique CSS class and field name for each, and set up a separate formatter and click handler for each one. The Claude Code prompt at the end of this guide can generate each button setup separately — just run it again with a different function name and field name.
Does this work with ERPNext as well as Frappe?
Yes. ERPNext is built on Frappe Framework and uses the same child table rendering system, formatters, event model, and locals object. Everything in this guide applies directly to ERPNext child tables including Sales Order Items, Purchase Order Items, Stock Entry Detail, and any custom child DocType.
Want More Practical Frappe and ERPNext Engineering Guides?
Auriga IT builds custom Frappe and ERPNext solutions for manufacturing, distribution, and retail. We publish practical implementation guides from real client projects.
If this guide helped you, explore more of our engineering breakdowns below.
Explore More GuidesRelated content
Auriga: Leveling Up for Enterprise Growth!
Auriga’s journey began in 2010 crafting products for India’s [...]






