Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 114 additions & 6 deletions builder/builder/doctype/builder_component/builder_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def before_insert(self):
self.component_id = frappe.generate_hash(length=16)
capture("builder_component_created", "builder")

def validate(self):
validate_component_slots(frappe.parse_json(self.block or "{}"))

def on_update(self):
# Skip the background cache-clear and version snapshot during bulk imports
# (install / migrate / import_doc). queue_action enqueues a job AND locks the
Expand Down Expand Up @@ -93,6 +96,51 @@ def update_exported_component(self):
)


def validate_component_slots(block: dict) -> None:
slot_names: set[str] = set()

def visit(
node: dict,
inside_slot: bool = False,
inside_repeater: bool = False,
is_root: bool = False,
) -> None:
if not is_root and node.get("extendedFromComponent"):
return
raw_slot_name = node.get("slotName")
if raw_slot_name is not None and (
not isinstance(raw_slot_name, str)
or not raw_slot_name.strip()
or raw_slot_name != raw_slot_name.strip()
):
frappe.throw(frappe._("Component slot names must be non-empty and trimmed"))
slot_name = raw_slot_name or ""
is_slot = bool(slot_name)
if is_slot:
if node.get("element") not in ("div", "section"):
frappe.throw(frappe._("Component slots must be container blocks"))
if inside_slot:
frappe.throw(frappe._("Component slots cannot be nested"))
if inside_repeater or node.get("isRepeaterBlock"):
frappe.throw(frappe._("Component slots cannot be placed inside collections"))
if slot_name in slot_names:
frappe.throw(
frappe._("Component slot names must be unique. Duplicate slot: {0}").format(slot_name)
)
slot_names.add(slot_name)
node["slotName"] = slot_name

for child in node.get("children") or []:
visit(
child,
inside_slot=inside_slot or is_slot,
inside_repeater=inside_repeater or bool(node.get("isRepeaterBlock")),
)

if isinstance(block, dict):
visit(block, is_root=True)


class ComponentSyncer:
def __init__(self, page_doc) -> None:
self.page_doc = page_doc
Expand All @@ -117,24 +165,84 @@ def sync_blocks(self, blocks: str | list[Block], component) -> str:
continue
if block.extendedFromComponent == component.component_id:
component_block = Block(**frappe.parse_json(component.block))
self.sync_single_block(block, component.name, component_block.children or [])
self.sync_single_block(block, component.name, component_block)
else:
self.sync_blocks(block.children or [], component)
blocks_dict = [block.as_dict() if isinstance(block, Block) else block for block in blocks_list]
return compact_json(blocks_dict)

def sync_single_block(self, target_block: Block, component_name: str, component_children: list[Block]):
def sync_single_block(
self,
target_block: Block,
component_name: str,
component_block: Block,
filled_slots: dict[str, Block] | None = None,
):
"""Sync a single block with its component template"""
if filled_slots is None:
filled_slots = self.collect_filled_slots(target_block)
target_block.slotName = component_block.slotName
root_slot = filled_slots.get(component_block.slotName) if component_block.slotName else None
if root_slot:
target_block.slotFilled = True
target_block.children = copy.deepcopy(root_slot.children or [])
return
target_block.slotFilled = False
if component_block.slotName:
target_block.children = self.create_detached_slot_content(component_block.children or [])
target_block.slotFilled = bool(target_block.children)
return
target_children = target_block.children or []
target_block.children = []
for index, component_child in enumerate(component_children):
for index, component_child in enumerate(component_block.children or []):
block_component = self.find_component_block(component_child.blockId, target_children)
if block_component:
self.sync_single_block(block_component, component_name, component_child.children or [])
else:
if not block_component:
block_component = self.create_component_block(component_child, component_name)
block_component.slotName = component_child.slotName
filled_slot = filled_slots.get(component_child.slotName) if component_child.slotName else None
if filled_slot:
block_component.slotFilled = True
block_component.children = copy.deepcopy(filled_slot.children or [])
else:
block_component.slotFilled = False
self.sync_single_block(block_component, component_name, component_child, filled_slots)
target_block.children.insert(index, block_component)

@staticmethod
def collect_filled_slots(root: Block) -> dict[str, Block]:
slots: dict[str, Block] = {}

def visit(block: Block, is_root: bool = False) -> None:
if not is_root and block.extendedFromComponent:
return
if block.slotName and block.slotFilled:
slots[block.slotName] = block
return
for child in block.children or []:
visit(child)

visit(root, True)
return slots

@staticmethod
def create_detached_slot_content(component_children: list[Block]) -> list[Block]:
children = copy.deepcopy(component_children)

def detach(block: Block, inside_nested_component: bool = False) -> None:
block.blockId = frappe.generate_hash(length=8)
if not inside_nested_component:
block.isChildOfComponent = None
block.referenceBlockId = None
if not block.extendedFromComponent:
block.componentVersion = None
inside_nested_component = inside_nested_component or bool(block.extendedFromComponent)
for child in block.children or []:
detach(child, inside_nested_component)

for child in children:
detach(child)
return children

@staticmethod
def parse_blocks(blocks: str) -> list[Block]:
"""Parse blocks JSON into Block objects"""
Expand Down
133 changes: 131 additions & 2 deletions builder/builder/doctype/builder_component/test_builder_component.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,138 @@
# Copyright (c) 2023, asdf and Contributors
# See license.txt

# import frappe
import copy

import frappe
from frappe.tests.utils import FrappeTestCase

from builder.builder.doctype.builder_component.builder_component import (
ComponentSyncer,
validate_component_slots,
)
from builder.builder.doctype.builder_page.builder_page import extend_block
from builder.utils import Block


class TestBuilderComponent(FrappeTestCase):
pass
def test_component_slot_names_must_be_unique(self):
block = {
"element": "div",
"children": [
{"element": "div", "slotName": "content"},
{"element": "section", "slotName": "content"},
],
}

with self.assertRaises(frappe.ValidationError):
validate_component_slots(block)

def test_component_slots_cannot_be_nested(self):
block = {
"element": "div",
"slotName": "outer",
"children": [{"element": "div", "slotName": "inner"}],
}

with self.assertRaises(frappe.ValidationError):
validate_component_slots(block)

def test_filled_slot_replaces_fallback_when_rendering(self):
component_slot = {
"element": "div",
"slotName": "content",
"children": [{"element": "p", "innerHTML": "Fallback"}],
}
instance_slot = {
"referenceBlockId": "slot",
"slotName": "content",
"slotFilled": True,
"children": [{"element": "h2", "innerHTML": "Custom"}],
}

result = extend_block(copy.deepcopy(component_slot), instance_slot)

self.assertEqual(result["children"], instance_slot["children"])
self.assertNotIn("Fallback", frappe.as_json(result))

def test_empty_filled_slot_does_not_render_fallback(self):
component_slot = {
"element": "div",
"slotName": "content",
"children": [{"element": "p", "innerHTML": "Fallback"}],
}

result = extend_block(
copy.deepcopy(component_slot),
{"slotName": "content", "slotFilled": True, "children": []},
)

self.assertEqual(result["children"], [])

def test_sync_preserves_filled_slot_by_name_after_move(self):
custom = Block(element="h2", innerHTML="Custom")
old_slot = Block(
blockId="instance-slot",
referenceBlockId="old-slot",
slotName="content",
slotFilled=True,
isChildOfComponent="component",
children=[custom],
)
target = Block(
extendedFromComponent="component",
children=[Block(referenceBlockId="old-wrapper", children=[old_slot])],
)
new_component = Block(
blockId="component-root",
element="div",
children=[
Block(
blockId="new-wrapper",
element="section",
children=[
Block(
blockId="new-slot",
element="div",
slotName="content",
children=[Block(blockId="fallback", element="p", innerHTML="Fallback")],
)
],
)
],
)

ComponentSyncer(None).sync_single_block(target, "component", new_component)

synced_slot = target.children[0].children[0]
self.assertEqual(synced_slot.slotName, "content")
self.assertTrue(synced_slot.slotFilled)
self.assertEqual(synced_slot.children[0].innerHTML, "Custom")

def test_sync_materializes_slot_defaults_as_detached_content(self):
target = Block(
extendedFromComponent="component",
slotName="content",
children=[],
)
component_slot = Block(
blockId="component-slot",
element="div",
slotName="content",
children=[
Block(
blockId="fallback",
element="p",
innerHTML="Fallback",
isChildOfComponent="component",
referenceBlockId="fallback",
)
],
)

ComponentSyncer(None).sync_single_block(target, "component", component_slot)

self.assertTrue(target.slotFilled)
self.assertEqual(target.children[0].innerHTML, "Fallback")
self.assertIsNone(target.children[0].isChildOfComponent)
self.assertIsNone(target.children[0].referenceBlockId)
31 changes: 31 additions & 0 deletions builder/builder/doctype/builder_page/builder_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -1489,6 +1489,10 @@ def extend_block(block, overridden_block):
block["innerHTML"] = overridden_block["innerHTML"]
component_children = block.get("children", []) or []
overridden_children = overridden_block.get("children", []) or []
if block.get("slotName") and overridden_block.get("slotFilled"):
block["children"] = copy.deepcopy(overridden_children)
block["slotFilled"] = True
return block
extended_children = []
for overridden_child in overridden_children:
component_child = next(
Expand Down Expand Up @@ -1553,6 +1557,10 @@ def resolve_path(path):
def reset_with_component(block, extended_with_component, component_children):
reset_block(block)
block["children"] = []
if block.get("slotName"):
block["children"] = create_detached_slot_content(component_children)
block["slotFilled"] = bool(block["children"])
return
for component_child in component_children:
component_child_id = component_child.get("blockId")
child_block = reset_block(component_child)
Expand All @@ -1574,6 +1582,27 @@ def reset_with_component(block, extended_with_component, component_children):
reset_with_component(child_block, extended_with_component, child_block.get("children"))


def create_detached_slot_content(component_children):
children = copy.deepcopy(component_children or [])

def detach(block, inside_nested_component=False):
block["blockId"] = frappe.generate_hash(length=8)
if not inside_nested_component:
block.pop("isChildOfComponent", None)
block.pop("referenceBlockId", None)
if not block.get("extendedFromComponent"):
block.pop("componentVersion", None)
inside_nested_component = inside_nested_component or bool(
block.get("extendedFromComponent")
)
for child in block.get("children") or []:
detach(child, inside_nested_component)

for child in children:
detach(child)
return children


def reset_block(block):
block["blockId"] = frappe.generate_hash(length=8)
block["innerHTML"] = None
Expand All @@ -1588,6 +1617,8 @@ def reset_block(block):
block["dataKey"] = {}
block["props"] = {}
block["dynamicValues"] = []
if block.get("slotName"):
block["slotFilled"] = False
return block


Expand Down
4 changes: 4 additions & 0 deletions builder/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ class Block:
originalElement: str | None = None
isChildOfComponent: str | None = None
referenceBlockId: str | None = None
slotName: str | None = None
slotFilled: bool = False
isRepeaterBlock: bool = False
visibilityCondition: str | VisibilityCondition | None = None
elementBeforeConversion: str | None = None
Expand Down Expand Up @@ -167,6 +169,8 @@ def as_dict(self):
"originalElement": self.originalElement,
"isChildOfComponent": self.isChildOfComponent,
"referenceBlockId": self.referenceBlockId,
"slotName": self.slotName,
"slotFilled": self.slotFilled,
"isRepeaterBlock": self.isRepeaterBlock,
"visibilityCondition": self.visibilityCondition,
"elementBeforeConversion": self.elementBeforeConversion,
Expand Down
Loading
Loading