DEV Community

Aakash Gupta
Aakash Gupta

Posted on • Originally published at Medium

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 the case of Frappe child tables, it is one of the rare things that is more complicated than normal.

This requires handling multiple layers of formatters, different layers for visibility and editing, click controllers, and data flow problems.

This blog contains the following:

  1. List of problems that need to be tackled in order to reliably add a button to the child table.
  2. Explanation of relevant technical details.
  3. Step-by-step implementation code.
  4. A detailed prompt for Claude Code to create the entire setup for you automatically.

This blog assumes a basic knowledge of working of Frappe, like adding custom fields and fetching data in Python functions.

If you want, you can skip to Part 4 and give the prompt to Claude Code — it will handle everything.


What's the Problem in Adding a Simple Button?

Just make a button field and add it to the columns

Well, Frappe doesn't render buttons in child tables. A button field in a child table will just render empty cells. That's why we need to step into the source code.

1. Just update the formatter

Right thought, but it needs a slight change.

Unlike normal formatters, we can't just update the local formatter, as it gets overwritten by the global formatter on every save, refresh, and row edit.

Frappe's internal formatter checker reads directly from the global, so that's where our override needs to go.

2. Well, that was simple

Not exactly. Even with this, the button will disappear when a row is being edited.

Child table cells are rendered in two stacked <div> layers. The formatter only affects one of them. The second layer will need a different method to render the button, so the buttons don't flicker.

3. Clicking the button will still open the child table row

Frappe adds a click listener to every cell in child table.

To prevent this, we need to intercept the click listener before Frappe knows about it.

4. Now how to tell the API which row's button was clicked

The solution seems simple, just pass the doc variable. Thing is, formatters don't have access to doc.

We'll need to use the HTML of child table to tell which row was clicked.

Let's first understand these problems properly, then we'll see how to solve them.


How Do Child Tables Work Under the Hood

Why does Frappe not render button fields in child tables?

It's hardcoded. Frappe's source explicitly skips rendering button fields in table view. We need a different field type and override the rendering ourselves.

The Formatter System

A formatter is a function that takes a value and returns HTML. It determines how a cell's value will look.

When Frappe renders a child table cell, it doesn't dump the field's value as-is. It passes the value through a formatter function first. The formatter decides what HTML gets rendered in that cell.

Also, each cell has its own formatter that runs every time a doc is saved or the page is refreshed.

How do we assign our own formatter to a field?

Every field definition is stored in a global object when a page is loaded:

frappe.meta.docfield_map['Stock Entry Detail']['your_field']
Enter fullscreen mode Exit fullscreen mode

This is not stored in source code, but is created when the browser loads.

For each field in each DocType, there's an object that holds all its attributes — unique, name, mandatory, etc.

One of the properties on this object is .formatter — a function. By default it's undefined, so Frappe renders the raw value.

For our task, we are adding our custom formatter here to render the button.

We set it to a custom formatter that returns button HTML:

frappe.meta.docfield_map['Stock Entry Detail']['your_field'].formatter = function(value, df) {
    return `<span class="my-btn">Click Me</span>`;
}
Enter fullscreen mode Exit fullscreen mode

We only need to update the global object. Frappe's internal formatter is copied from it every time the page is reloaded.


The Two Layers

Every cell in a child table row has two <div> layers stacked on top of each other:

  • static-area — the display layer. Visible when the row is not selected. This is where your formatter's HTML is rendered.
  • field-area — the edit layer. Where the input field appears when editing. Not touched by the formatter.

On normal view, static-area is visible and field-area is hidden.

This is also the only layer that is updated by the formatter.

When the user clicks any cell in a row, Frappe switches the entire row to edit mode. It hides static-area and shows field-area for every column in that row — including your button column.

Our formatter added the button, but it is hidden on edit as the entire layer is hidden. We'll need custom CSS to keep the display layer visible for the button column.


The Click System

Every row in a child table has a click listener. Click anywhere on the row — if the cell is not editable, Frappe opens that row for editing. If it is, the cell becomes editable.

Browsers process click events in two phases:

  • Capture phase — the click travels from the outermost element (the page) down through every parent: form wrapper → table → row → cell → button.
  • Bubble phase — the click then travels bottom-up, from the clicked element back to the page wrapper.

Frappe's row-open listener runs in the bubble phase. We will attach our listener in the capture phase, so it runs first — before Frappe ever sees the click.

Our listener sits at the form wrapper level during capture phase. Every time there's a click, it checks if the click landed on the button column, and if so, intercepts it.


Formatters Don't Have Access to doc

The formatter signature is:

formatter(value, df)
Enter fullscreen mode Exit fullscreen mode

That's it. Just the cell's value and the field definition. No doc, no row context.

This is intentional. A child table can have 50, 100, 200 rows, each with multiple columns. Frappe calls the formatter for every single cell during every render. Passing the full doc object to each call would multiply memory and processing overhead fast.

But now, how do we tell the button's function which row's button was clicked? That was the whole point of adding a button to the rows.

We will use the DOM structure of the child table for this.

Frappe renders every child table row like this:

<div class="grid-row" data-name="abc123def456">
    ...cells...
</div>
Enter fullscreen mode Exit fullscreen mode

Every row has a data-name attribute — the row's unique ID, called CDN (Child DocName) in Frappe. CDNs are stable unique IDs — unlike row indices, they don't shift when rows are reordered or deleted.

When the button is clicked, we walk up the DOM from the button to find its parent .grid-row, then read the data-name:

const gridRowEl = btn.closest('.grid-row');
const cdn = gridRowEl.dataset.name;
Enter fullscreen mode Exit fullscreen mode

This CDN is passed to the API. On the server side, Frappe can fetch the full row data using this ID — item code, quantity, warehouse, everything.

The flow:

Button clicked → Read CDN from DOM → Pass CDN to API → API fetches full row data using CDN
Enter fullscreen mode Exit fullscreen mode

No doc needed anywhere in the frontend.


Now that we understand the internals, let's implement it.


Implementing It

We'll build this step by step. Each step solves one of the problems we discussed.

For this example, we're adding a button to YOUR_CHILD_DOCTYPE that opens a popup showing the item name of that row.

Step 1 — Add a Data field to your child table

Add a Data field (not a Button field). Mark it as In List View so it appears as a column.

We'll override how it looks using the formatter.


Step 2 — Override the global formatter

Add the formatter override code in the JS file of the concerned DocType.

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 `<span class="my-action-btn"
                      style="cursor:pointer; color:var(--primary); text-decoration:underline;">
                    Click Me
                </span>`;
    };
}
Enter fullscreen mode Exit fullscreen mode

Call this in both setup and refresh hooks of your parent form:

frappe.ui.form.on('YOUR_DOCTYPE', {
    setup(frm) {
        apply_button_formatter();
    },
    refresh(frm) {
        apply_button_formatter();
    }
});
Enter fullscreen mode Exit fullscreen mode

setup runs once on initial form load. refresh re-applies after every save, because Frappe resets the global meta from the server on each refresh.


Step 3 — Keep the button visible in edit mode (CSS)

Add this to the CSS file of the project.

.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;
}
Enter fullscreen mode Exit fullscreen mode

This forces the display layer to stay visible and hides the edit layer for your button column — even when the row is in edit mode.

Note: This CSS is global — it applies to all DocTypes that have a field with this name. That's why you should prefix your field name with the DocType name (e.g. se_detail_check_stock instead of just check_stock). Rename the label in Customize Form to keep the column header clean.


Step 4 — Intercept the click

Add this to the JS file of the concerned DocType.

function setup_button_handler(frm) {
    // Remove previous listener to prevent stacking on each refresh
    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;

        // Kill the event — Frappe never sees it
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();

        // Get the row's unique ID from the DOM
        const gridRow = btn.closest('.grid-row');
        const cdn = gridRow && gridRow.dataset.name;

        if (cdn) {
            handle_button_click(cdn);
        }
    };

    // true = capture phase — runs before Frappe's bubble phase listener
    frm.wrapper.addEventListener('click', frm._my_btn_handler, true);
}
Enter fullscreen mode Exit fullscreen mode

Call this alongside apply_button_formatter() in setup and refresh.

Two things to note:

  • e.stopImmediatePropagation() kills the event completely — no other listener will see it.
  • The handler is stored on frm so we can remove it before re-adding, preventing duplicate handlers stacking up on each refresh.

Step 5 — The action function

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

    frappe.msgprint(`Item: ${row.item_code}`);
}
Enter fullscreen mode Exit fullscreen mode

locals is Frappe's in-memory store of all loaded documents. Every child row loaded on the form is available here by its CDN. No API call needed — the data is already in memory.

For server-side operations, you can pass the CDN to a whitelisted API method and fetch the row details there.


Part 4 — 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
Enter fullscreen mode Exit fullscreen mode

Claude will walk you through the setup interactively — ask for DocType names, generate the code, and place it in the right 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. Common locations:
  - `[app]/[app]/[module]/doctype/[doctype]/[doctype].js` (if this is a custom doctype in the app)
  - `[app]/[app]/public/js/[doctype].js` (if overriding a core doctype)
  - Check `hooks.py` for `doctype_js` entries that map to this 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 — add the calls to `setup` and `refresh` hooks inside the existing block. Do not create a duplicate registration.

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:

```javascript
function apply_[function_name]_formatter() {
    const map = frappe.meta.docfield_map['[CHILD_DOCTYPE]'];
    if (!map) return;

    map['[field_name]'].formatter = function(value, df) {
        return `<span class="[function_name]-btn"
                      style="cursor:pointer; color:var(--primary); text-decoration:underline;">
                    [BUTTON_LABEL]
                </span>`;
    };
}

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:

```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 — Fill in the action function

Based on what the user said the function should do (from question 5), generate the function body.

The function receives `frm` (the parent form object) and `cdn` (the child row's unique ID).

To access row data, there are three patterns. Pick the right one based on the user's description:

**Pattern A — Frontend only (default):**
Use when the action only needs to read/display row data, or update fields on the form.
```javascript
const row = locals['[CHILD_DOCTYPE]'][cdn];
// row.field_name gives you any field value
```
`locals` is Frappe's in-memory store — every loaded child row is available by CDN. No API call needed.

**Pattern B — Server call:**
Use when the action needs to trigger server-side logic (database writes, external API calls, validations that need server context).
```javascript
frappe.call({
    method: '[app].[module].api.[method_name]',
    args: { cdn: cdn },
    callback: function(r) {
        // handle response
        frm.reload_doc();
    }
});
```
On the server side, create a whitelisted method that receives the CDN and fetches row data with:
```python
@frappe.whitelist()
def method_name(cdn):
    row = frappe.get_doc('[CHILD_DOCTYPE]', cdn)
```

**Pattern C — Both:**
Use when you need to read some fields immediately for display (frontend) and also trigger a server operation.
```javascript
const row = locals['[CHILD_DOCTYPE]'][cdn];
// use row data for immediate UI feedback
frappe.call({ ... });
```

Generate the complete function body using the appropriate pattern. Include a brief comment explaining which pattern was used and why.

## Step 5 — Remind the user

After writing all code, tell the user:
- Run `bench build` and reload the page
- The CSS is global (applies to all forms with a field of that name) — that's why we prefixed the field name with the doctype name
- If they need a second button on the same child table, they can run this prompt again with a different function name and field name
Enter fullscreen mode Exit fullscreen mode

Top comments (0)