Skip to content

Dynamic Link for Polymorphic References

Problem: Build a “Note” system that can attach to ANY DocType — Franchise Outlet, Employee, Sales Order, and more — without creating a separate note table for each one.

Solution: Use Frappe’s Dynamic Link field pair: a Link field that points at DocType plus a Dynamic Link field whose options name that first field. The Dynamic Link resolves its target at runtime from whatever DocType the Link field holds, so one SJ Note table can reference everything.

The pairing lives in the field definitions: reference_doctype is a Link to the DocType registry, and reference_name is a Dynamic Link whose options is the fieldname reference_doctype (not a DocType name). That options indirection is what makes the second field polymorphic.

scoopjoy/scoopjoy/doctype/sj_note/sj_note.json
{
"name": "SJ Note",
"module": "ScoopJoy",
"naming_rule": "Expression",
"autoname": "SJ-NOTE-.#####",
"fields": [
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Type",
"options": "DocType",
"reqd": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"columns": 2
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"label": "Reference Document",
"options": "reference_doctype",
"reqd": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"columns": 2
},
{ "fieldname": "column_break_1", "fieldtype": "Column Break" },
{
"fieldname": "note_type",
"fieldtype": "Select",
"label": "Note Type",
"options": "\nGeneral\nComplaint\nFollowup\nAudit\nCompliance",
"default": "General",
"in_list_view": 1,
"in_standard_filter": 1,
"columns": 1
},
{
"fieldname": "priority",
"fieldtype": "Select",
"label": "Priority",
"options": "\nLow\nMedium\nHigh\nCritical",
"default": "Medium",
"in_list_view": 1,
"columns": 1
},
{ "fieldname": "section_content", "fieldtype": "Section Break", "label": "Content" },
{
"fieldname": "subject",
"fieldtype": "Data",
"label": "Subject",
"reqd": 1,
"in_list_view": 1,
"columns": 3
},
{ "fieldname": "content", "fieldtype": "Text Editor", "label": "Content", "reqd": 1 },
{ "fieldname": "section_meta", "fieldtype": "Section Break", "label": "Metadata" },
{ "fieldname": "resolved", "fieldtype": "Check", "label": "Resolved", "default": "0" },
{
"fieldname": "resolved_by",
"fieldtype": "Link",
"label": "Resolved By",
"options": "User",
"depends_on": "eval:doc.resolved",
"mandatory_depends_on": "eval:doc.resolved"
},
{
"fieldname": "resolved_on",
"fieldtype": "Datetime",
"label": "Resolved On",
"depends_on": "eval:doc.resolved",
"read_only": 1
}
],
"permissions": [
{ "role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1 },
{ "role": "Franchise Manager", "read": 1, "write": 1, "create": 1 }
],
"sort_field": "creation",
"sort_order": "DESC",
"track_changes": 1
}

Step 2: Controller with cross-DocType validation

Section titled “Step 2: Controller with cross-DocType validation”

Because any DocType could be selected, the controller polices which ones should be. An allow-list keeps notes from attaching to junk types, and frappe.db.exists confirms the referenced document is real before saving.

scoopjoy/scoopjoy/doctype/sj_note/sj_note.py
import frappe
from frappe import _
from frappe.model.document import Document
# DocTypes that are allowed to have notes attached
ALLOWED_DOCTYPES = [
"SJ Franchise Outlet",
"SJ Franchise Agreement",
"Employee",
"Sales Order",
"Sales Invoice",
"Customer",
"Supplier",
"Item",
]
class SJNote(Document):
def validate(self):
self.validate_reference_doctype()
self.validate_reference_exists()
self.handle_resolution()
def validate_reference_doctype(self):
"""Restrict which DocTypes can have notes."""
if self.reference_doctype not in ALLOWED_DOCTYPES:
frappe.throw(
_("Notes cannot be attached to {0}. Allowed types: {1}").format(
self.reference_doctype, ", ".join(ALLOWED_DOCTYPES)
)
)
def validate_reference_exists(self):
"""Ensure the referenced document actually exists."""
if not frappe.db.exists(self.reference_doctype, self.reference_name):
frappe.throw(
_("{0} '{1}' does not exist.").format(
self.reference_doctype, self.reference_name
)
)
def handle_resolution(self):
"""Auto-set resolved_on when resolved is checked."""
if self.resolved and not self.resolved_on:
self.resolved_on = frappe.utils.now_datetime()
if not self.resolved_by:
self.resolved_by = frappe.session.user
@frappe.whitelist()
def get_notes_for_document(reference_doctype, reference_name):
"""API: Get all notes for a specific document."""
frappe.has_permission("SJ Note", "read", throw=True)
return frappe.get_all(
"SJ Note",
filters={
"reference_doctype": reference_doctype,
"reference_name": reference_name,
},
fields=["name", "subject", "note_type", "priority", "resolved", "creation", "owner"],
order_by="creation desc",
)
@frappe.whitelist()
def get_note_count_for_document(reference_doctype, reference_name):
"""API: Quick count for badge display."""
return frappe.db.count(
"SJ Note",
filters={
"reference_doctype": reference_doctype,
"reference_name": reference_name,
},
)

Step 3: Auto-delete notes via a doc_events hook

Section titled “Step 3: Auto-delete notes via a doc_events hook”

A polymorphic link has no foreign-key cascade, so when a referenced document is trashed its notes would be orphaned. Wire an on_trash handler for each DocType that can carry notes.

scoopjoy/hooks.py
doc_events = {
"SJ Franchise Outlet": {
"on_trash": "scoopjoy.utils.notes.delete_linked_notes",
},
"SJ Franchise Agreement": {
"on_trash": "scoopjoy.utils.notes.delete_linked_notes",
},
"Customer": {
"on_trash": "scoopjoy.utils.notes.delete_linked_notes",
},
"Sales Order": {
"on_trash": "scoopjoy.utils.notes.delete_linked_notes",
},
# Add more DocTypes as needed
}

The shared handler looks up every note pointing at the document and force-deletes them, then alerts the user.

scoopjoy/scoopjoy/utils/notes.py
import frappe
def delete_linked_notes(doc, method):
"""Delete all SJ Notes linked to a document being trashed."""
notes = frappe.get_all(
"SJ Note",
filters={
"reference_doctype": doc.doctype,
"reference_name": doc.name,
},
pluck="name",
)
for note_name in notes:
frappe.delete_doc("SJ Note", note_name, force=True, ignore_permissions=True)
if notes:
frappe.msgprint(
f"Deleted {len(notes)} linked note(s).",
indicator="orange",
alert=True,
)

Step 4: Client script — show notes on any form

Section titled “Step 4: Client script — show notes on any form”

A single reusable function reads the note count, drops a dashboard indicator, and adds “View Notes” / “Add Note” buttons that pre-fill the Dynamic Link pair from the current form. The buttons pass frm.doc.doctype and frm.doc.name, so the same code works on every form you bind it to.

scoopjoy/public/js/sj_notes_widget.js
// Include in hooks.py: app_include_js = ["/assets/scoopjoy/js/sj_notes_widget.js"]
frappe.ui.form.on("SJ Franchise Outlet", {
refresh(frm) {
scoopjoy.notes.add_notes_section(frm);
},
});
// Reusable for any DocType
frappe.provide("scoopjoy.notes");
scoopjoy.notes.add_notes_section = function (frm) {
if (frm.is_new()) return;
frappe.call({
method: "scoopjoy.scoopjoy.doctype.sj_note.sj_note.get_note_count_for_document",
args: {
reference_doctype: frm.doc.doctype,
reference_name: frm.doc.name,
},
callback: function (r) {
const count = r.message || 0;
frm.dashboard.add_indicator(
`Notes: ${count}`,
count > 0 ? "blue" : "grey"
);
},
});
frm.add_custom_button(
__("View Notes ({0})", [""]),
function () {
frappe.set_route("List", "SJ Note", {
reference_doctype: frm.doc.doctype,
reference_name: frm.doc.name,
});
},
__("Notes")
);
frm.add_custom_button(
__("Add Note"),
function () {
frappe.new_doc("SJ Note", {
reference_doctype: frm.doc.doctype,
reference_name: frm.doc.name,
});
},
__("Notes")
);
};