Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Grandchild Table Fields

Render editable tables inside child table rows by storing the grandchild data as JSON.

Updated
Tags
  • frappe
  • child-table
  • javascript
  • json
Reading time
4 min

Frappe doesn't natively support tables within child table rows (grandchild tables). This pattern works around the limitation using JSON storage and virtual table rendering.

The Problem

You have a parent document with a child table (e.g., Shipping Package → Item Capacity rows). Each child row needs its own editable sub-table of data (e.g., multiple UOM/quantity pairs per item capacity slot).

Frappe's child table (Table fieldtype) only supports one level of nesting.

The Solution

  1. Store grandchild data as JSON in a field on the child doctype

  2. Render a virtual editable table when the child row's edit form opens

  3. Sync changes back to the JSON field on edit

Doctype Setup

Parent: Shipping Package

Has a standard Table field:

{
    "fieldname": "item_capacity",
    "fieldtype": "Table",
    "options": "Shipping Package Item Capacity"
}

Child: Shipping Package Item Capacity

Has a JSON field for grandchild data and a Data field for display summary:

{
    "fields": [
        {
            "fieldname": "capacity_sets",
            "fieldtype": "JSON",
            "label": "Capacity Sets"
        },
        {
            "fieldname": "description",
            "fieldtype": "Data",
            "label": "Description",
            "in_list_view": 1
        }
    ],
    "istable": 1
}

The description field shows a human-readable summary in the grid (e.g., "Pound × 5 | Ounce × 10").

JavaScript Implementation

Hook into the child table's form_render event to replace the JSON field with a virtual table:

frappe.ui.form.on("Shipping Package Item Capacity", {
    form_render: function (frm, cdt, cdn) {
        let child_row = locals[cdt][cdn];
        let dialog = frm.fields_dict.item_capacity.grid.open_grid_row;

        if (!dialog) return;

        // Get the wrapper for the capacity_sets field
        let wrapper = dialog.fields_dict.capacity_sets.wrapper;
        wrapper.replaceChildren();

        // Create an in-memory FieldGroup with a virtual Table field
        let field_group = new frappe.ui.FieldGroup({
            fields: [
                {
                    fieldtype: "Table",
                    fieldname: "capacity_table",
                    in_place_edit: true,
                    data: JSON.parse(child_row.capacity_sets || "[]"),
                    fields: [
                        {
                            fieldname: "uom",
                            label: "UOM",
                            fieldtype: "Link",
                            options: "UOM",
                            in_list_view: 1,
                            reqd: 1,
                        },
                        {
                            fieldname: "qty",
                            label: "Quantity",
                            fieldtype: "Int",
                            in_list_view: 1,
                            reqd: 1,
                        },
                    ],
                    get_data: () => field_group.get_value("capacity_table"),
                },
            ],
            body: wrapper,
        });
        field_group.make();

        // Connect virtual grid to parent document
        // (required for Link fields to resolve properly)
        field_group.fields_dict.capacity_table.grid.doc = frm.doc;

        // Sync changes back to the JSON field
        field_group.fields_dict.capacity_table.grid.wrapper.on(
            "change",
            () => {
                let data = field_group.get_value("capacity_table");

                // Sort for consistent display
                data.sort((a, b) => (a.uom || "").localeCompare(b.uom || ""));

                // Store as JSON
                child_row.capacity_sets = JSON.stringify(data);

                // Update the summary description for the grid view
                child_row.description = data
                    .filter((row) => row.qty && row.uom)
                    .map((row) => `${row.uom} × ${row.qty}`)
                    .join(" | ");

                frm.dirty();
            }
        );
    },
});

How It Works

  1. form_render event fires when a child row's inline form opens (clicking the row or the expand button).

  2. Access the child row data via locals[cdt][cdn] — this is the standard Frappe pattern for accessing child document data in form events.

  3. Get the grid dialog via frm.fields_dict.item_capacity.grid.open_grid_row — this is the currently open row's form container.

  4. Replace the JSON field's wrapper — wrapper.replaceChildren() clears the default JSON textarea.

  5. Create a frappe.ui.FieldGroup with a Table fieldtype — this renders a full editable grid inside the child form. Key properties:

    • in_place_edit: true — Enables direct cell editing

    • data — Initial values parsed from the JSON field

    • fields — Column definitions (Link, Int, Float, etc.)

    • get_data — Callback for the grid to fetch current data

  6. Connect to parent document — field_group.fields_dict.capacity_table.grid.doc = frm.doc ensures Link fields can access the parent document's context.

  7. Sync on change — The grid wrapper's change event serializes data back to JSON, updates the display summary, and marks the form dirty.

Data Flow

Database:  capacity_sets = '[{"uom":"Pound","qty":5},{"uom":"Ounce","qty":10}]'
                ↓ parse
Virtual table: | UOM    | Qty |
               | Pound  | 5   |
               | Ounce  | 10  |
                ↓ serialize on change
JSON field: '[{"uom":"Ounce","qty":10},{"uom":"Pound","qty":5}]'  (sorted)
Summary:    "Ounce × 10 | Pound × 5"

Considerations

  • No server-side validation of grandchild structure — The JSON field stores arbitrary data. Add Python-side validation in the parent's validate() method if needed.

  • No query access — You can't filter or search by grandchild data using Frappe's query builder. Use JSON_EXTRACT in raw SQL if needed.

  • Performance — This pattern works well for small amounts of grandchild data (< 50 rows). For larger datasets, consider a proper child doctype with a Link back to the parent's child row.

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.