Add button to Child Table in Frappe

Published On: 3 April 2026.By .
Quick Answer

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.

Frappe · ERPNext · Developer Guide

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.

Frappe Framework Formatter Override Click Handling CSS Layer Fix Claude Code Prompt
4 Core Problems to Solve
5 Implementation Steps
1 Ready Claude Prompt

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.

Problems

Why a Normal Button Field Fails

Everything that blocks a standard Button field from working in child table list view.

Internals

How Frappe Renders Child Tables

How formatters, render layers, click events, and row IDs actually work under the hood.

Code

Step-by-Step Implementation

The exact JavaScript and CSS to set up — field, formatter, CSS fix, click handler, and action function.

Automation

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.

Problem 01

A Button Field Won't Render

In child table list view, a Button field renders as an empty cell. The normal approach fails immediately.

Problem 02

Formatter Overrides Don't Stick

Even if you override the formatter, Frappe can overwrite it on save, refresh, and edit cycles.

Problem 03

Button Disappears in Edit Mode

A child table cell has separate display and edit layers. Your formatter only affects the display layer.

Problem 04

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.

Problem 05

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

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 you need to use a different field type and control the cell output manually through the formatter system.

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

JavaScript — Formatter Override
frappe.meta.docfield_map['Stock Entry Detail']['your_field'].formatter = function(value, df) {
  return `Click Me`;
}
3

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.

4

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.

5

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.

JavaScript — Read CDN from DOM
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.

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

2

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.

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();
  }
});
3

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.

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

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.

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 click handling opens the row. Calling stopImmediatePropagation() prevents every other listener from seeing the event.

JavaScript — Capture-Phase Click Handler
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);
}
5

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.

JavaScript — Frontend-Only Row Read
function handle_button_click(cdn) {
  const row = locals['YOUR_CHILD_DOCTYPE'][cdn];
  if (!row) return;

  frappe.msgprint(`Item: ${row.item_code}`);
}
JavaScript — Server-Side Call
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.

JavaScript — Complete Implementation
// 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);
  }
});
CSS — Complete Implementation
/* 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:

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

child_table_button.md — Full 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 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 Guides

Related content

Stay Close to What We’re Building

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

Aakash Gupta
Aakash Gupta
Aakash Gupta is an SDE-1 at Auriga IT working on Frappe and ERPNext implementations. He is certified in Claude Code and AI agent skills, and has built productivity tools including a Chrome extension for prompt management.
Go to Top