Add button to Child Table in Frappe

Published On: 3 April 2026.By .
How to Add a Clickable Button to a Frappe Child Table | Practical Guide
Frappe  ·  ERPNext  ·  Developer Guide

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.

Child Tables
Formatter Override
Click Handling
CSS Layer Fix
Claude Code Prompt

Published: April 2026  ·  By Auriga IT

4
Core Problems to Solve
5
Implementation Steps
1
Ready Claude Prompt
2
Render Layers
Capture
Event Phase Used
0 API
Needed for Basic Row Read
01 — Overview

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.

Problems to Solve
Everything that blocks a normal button field from working
Technical Internals
How formatters, layers, events, and child row IDs actually work
Step-by-Step Code
The exact JS and CSS setup to implement
Claude Prompt
A full automation prompt to generate the setup for you
Assumption: This guide assumes you already understand basic Frappe customization such as adding fields and reading data in Python functions.
Pro Tip: If you want, you can skip directly to Part 4 and give the prompt to Claude Code. It can build the setup for you automatically.
02 — In This Blog

The Roadmap

03 — The Problem

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.

01
A Button Field Won’t Render
In child table list view, a Button field just renders as an empty cell. So the normal “just add a button field” approach fails immediately.
02
Formatter Overrides Don’t Stick Locally
Even if you use a formatter, changing the local version is not enough because Frappe can overwrite it on save, refresh, and edit cycles.
03
The Button Disappears in Edit Mode
A child table cell has separate display and edit layers. Your formatter affects one of them, not both.
04
Clicks Open the Row Instead
Frappe already listens for clicks on child table cells, so clicking your fake button still triggers row edit behavior unless you intercept it first.
05
The Formatter Doesn’t Get the Row Doc
The formatter only gets value and df. It does not get the full child row, so row-level behavior needs a different approach.
04 — Internals

How Child Tables Work Under the Hood

1

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.

2

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.

Formatter Override
frappe.meta.docfield_map['Stock Entry Detail']['your_field'].formatter = function(value, df) {
  return `Click Me`;
}
Important: In practice you should override the global docfield map entry, not a temporary local copy, because Frappe reuses the global meta while rendering.
3

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.

4

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.

5

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.

Read CDN from DOM
const gridRowEl = btn.closest('.grid-row');
const cdn = gridRowEl.dataset.name;
05 — Step by Step

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.

1

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.

2

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.

JavaScript
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();
  }
});
Pro Tip: setup runs once on load, while refresh re-applies after saves because Frappe can reset the meta.
3

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.

CSS
.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;
}
Pro Tip: This CSS is global. Prefix the field name with the child DocType context so it does not accidentally affect other forms.
4

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.

JavaScript
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);
}
Pro Tip: stopImmediatePropagation() prevents every other click listener from seeing the event.
5

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.

JavaScript
function handle_button_click(cdn) {
  const row = locals['YOUR_CHILD_DOCTYPE'][cdn];
  if (!row) return;

  frappe.msgprint(`Item: ${row.item_code}`);
}
Pro Tip: For server-side operations, pass the CDN to a whitelisted method and fetch the child row there.
06 — Automation

Let Claude Code Do It

Save the prompt below as child_table_button.md in your project root. Then open Claude Code and run:

Command
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.

P

The Prompt

child_table_button.md
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
07 — FAQ

Questions About This Setup

Why not just use a Button field in the child table?
+
Because Frappe does not render button fields in child table list view. They show up as empty cells, so you need to simulate the button using a Data field plus a formatter.
Why does the button disappear when I click the row?
+
Because the formatter only affects the display layer. When the row enters edit mode, Frappe hides that layer and shows the edit layer instead. You need CSS to keep the display layer visible for that column.
Why use capture phase for the click handler?
+
Frappe’s row click logic runs later in the bubble phase. A capture-phase listener lets your code intercept and stop the event before Frappe can open the row.
How do I know which child row was clicked?
+
Read the 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.
Do I always need an API call for the button action?
+
No. If you just need the already loaded child row values, 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 →
Frappe Engineering
×
Auriga IT

Guide — How to Add a Clickable Button to a Frappe Child Table  ·  © Auriga IT 2026

Related content

Stay Close to What We’re Building

Get insights on product engineering, AI, and real-world technology decisions shaping modern businesses.

Go to Top