Child Table Inside Child Table in Frappe: A Practical Workaround
One of the most common requirements in Frappe/ERPNext projects is to have a child table inside another child table. While this sounds simple, the framework does not support nested child tables out of
The Problem
A very common client requirement:
“Can we have a child table inside another child table?”
This usually comes up in use cases like:
- Dynamic forms
- Surveys
- Exams (Questions → Options)
- Config-driven UI
However, in Frappe:
❌ Child tables cannot contain another child table
❌ Only one level of table is supported
The Approach
Instead of forcing the framework, we simulate the behavior.
Idea:
- Store the "grandchild table" as JSON
- Render it dynamically using
frappe.ui.FieldGroup - Sync changes back to the JSON field
Data Model
Parent DocType (Exam)
| Field | Type |
|---|---|
| subject | Data |
| questions | Table (Child) |
Child DocType (Questions)
| Field | Type | Description |
|---|---|---|
| question | Data / Small Text | Question text |
| options_json | Code | Stores options as JSON |
Important: Use Code field, not Data
Grandchild (Stored as JSON)
Example:
[
{
"option": "Microsoft",
"is_correct": 0
},
{
"option": "Netscape",
"is_correct": 1
},
{
"option": "Google",
"is_correct": 0
},
{
"option": "Oracle",
"is_correct": 0
}
]
JavaScript Implementation
We use form_render to inject a nested table UI inside the child row dialog.
frappe.ui.form.on('Questions', {
form_render: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
// Access child table grid
let grid = frm.fields_dict.questions;
if (!grid || !grid.grid) return;
// Get open row dialog
let dialog = grid.grid.open_grid_row;
if (!dialog) return;
// Target JSON field wrapper
let wrapper = dialog.fields_dict.options_json.wrapper;
wrapper.innerHTML = '';
// Create dynamic table using FieldGroup
let field_group = new frappe.ui.FieldGroup({
fields: [{
fieldtype: 'Table',
fieldname: 'options_table',
label: 'Options',
in_place_edit: true,
data: JSON.parse(row.options_json || '[]'),
fields: [
{
fieldname: 'option',
label: 'Option',
fieldtype: 'Data',
in_list_view: 1,
reqd: 1
},
{
fieldname: 'is_correct',
label: 'Correct',
fieldtype: 'Check',
in_list_view: 1
}
]
}],
body: wrapper
});
field_group.make();
// Sync data back to JSON
field_group.fields_dict.options_table.grid.wrapper.on('change', () => {
row.options_json = JSON.stringify(
field_group.get_value('options_table')
);
frm.dirty();
});
}
});
Result
This creates:
- A nested table inside child row dialog
- Editable options per question
- Dynamic and flexible UI
Limitations
This is a workaround, not native support.
Keep in mind:
- ❌ Not suitable for reporting
- ❌ No DB-level structure for nested data
- ❌ Requires manual validation
When to Use This
This approach works best for:
- UI-heavy configurations
- Dynamic forms
- Small datasets
- Non-reporting use cases
Final Thoughts
Instead of fighting the framework, this approach works with it.
It gives:
- Flexibility
- Clean UI
- Quick implementation
Without modifying core behavior.
```
No comments yet. Login to start a new discussion Start a new discussion