From 0c5af9e2b5f980ca9fb7c67e8738a578354e89af Mon Sep 17 00:00:00 2001 From: sayantan Date: Tue, 7 Apr 2026 01:55:29 +0530 Subject: [PATCH 1/3] feat: client hydration using alpineJS --- .../doctype/builder_page/builder_page.py | 44 +++++++++---- builder/hooks.py | 2 +- builder/templates/generators/webpage.html | 16 +++++ builder/utils.py | 63 ++++++++++++++++--- 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index dc440073d..0ac9e6dc9 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -675,7 +675,7 @@ def get_dynamic_props_template( ) -> str: """Get a Jinja template reference for dynamic properties.""" if comes_from == "blockDataScript": - key = jinja_safe_key(f"block.{prop_value}") + key = jinja_safe_key(f"block_data.{prop_value}") elif comes_from == "props": key = jinja_safe_key(f"props.{prop_value}") else: # dataScript @@ -883,8 +883,8 @@ def get_loop_info(block: dict, data_key: dict | None, props_stack: dict) -> dict elif comes_from == "blockDataScript": return { - "loop_var": "block", - "iterator_key": jinja_safe_key(f"block.{iterator_key}"), + "loop_var": "block_data", + "iterator_key": jinja_safe_key(f"block_data.{iterator_key}"), "data_key": data_key, } @@ -941,7 +941,7 @@ def get_visibility_condition_key(block: dict, data_key: dict | None) -> str | No if comes_from == "props": return jinja_safe_key(f"props.{key}") elif comes_from == "blockDataScript": - return jinja_safe_key(f"block.{key}") + return jinja_safe_key(f"block_data.{key}") else: # dataScript if data_key: return f"{extract_data_key(data_key)}.{key}" @@ -968,15 +968,23 @@ def attach_client_script(tag: bs.Tag, block: dict, state: dict): # Add data attribute for selecting this specific block tag.attrs["data-block-uid"] = "{{ unique_hash }}" + tag.attrs["x-data"] = "data_{{ unique_hash }}" + + alpine_loader_code = "loadAlpineData('data_{{ unique_hash }}', {{ own_block_data | to_safe_json }});" # Add local script to call the function local_script = state["soup"].new_tag("script") local_script.string = ( - f"(client_script_{script_unique_id}).call(" - f"document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]'), " - f"{{{{ props | to_safe_json }}}}, " - f"{{{{ block.block_data | to_safe_json }}}}" - f");" + f"document.addEventListener('alpine:initialized', async () => {{" + f" const element = document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]');" + f" const block_data = Alpine.$data(element);" + f" (client_script_{script_unique_id}).call(" + f" element, " + f" {{{{ props | to_safe_json }}}}, " + f" block_data" + f" );" + f"}});" + f"{alpine_loader_code}" ) tag.append(local_script) @@ -1000,7 +1008,10 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict): if context.get("block_data_script"): escaped_script = escape_single_quotes(context["block_data_script"]) - parent.append(f"{{% with block = block | execute_script_and_combine('{escaped_script}', props) %}}") + parent.append( + f"{{% with own_block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}" + ) + parent.append("{% with block_data = own_block_data | combine(block_data) %}") if context.get("visibility_key"): parent.append(f"{{% if {context['visibility_key']} %}}") @@ -1012,6 +1023,7 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict): if context.get("block_data_script"): parent.append("{% endwith %}") + parent.append("{% endwith %}") parent.append("{% endwith %}") parent.append("{% endwith %}") @@ -1045,6 +1057,8 @@ def set_dynamic_content_placeholders(block: dict, data_key: dict | None = None): if value_type == "attribute": current_value = block["attributes"].get(property_name, "") block["attributes"][property_name] = f"{{{{ {key} or '{escape_single_quotes(current_value)}' }}}}" + if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": + block["attributes"][f"x-bind:{property_name}"] = original_key elif value_type == "style": if not block["attributes"].get("style"): @@ -1055,12 +1069,18 @@ def set_dynamic_content_placeholders(block: dict, data_key: dict | None = None): block["attributes"]["style"] += ( f"{css_property}: {{{{ {key} or '{escape_single_quotes(current_value)}' }}}};" ) + if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": + block["attributes"]["x-bind:style"] = f"{css_property}: {original_key}" elif value_type == "key" and not block.get("isRepeaterBlock"): current_value = block.get(property_name, "") block[property_name] = ( f"{{{{ {key} if {key} or {key} in ['', 0] else '{escape_single_quotes(current_value)}' }}}}" ) + if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": + block["attributes"][ + "x-text" if property_name == "innerHTML" else f"x-bind:{property_name}" + ] = original_key def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key: dict | None) -> str: @@ -1070,7 +1090,7 @@ def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key: if comes_from == "props": return jinja_safe_key(f"props.{original_key}") elif comes_from == "blockDataScript": - return jinja_safe_key(f"block.{original_key}") + return jinja_safe_key(f"block_data.{original_key}") else: # dataScript key = dynamic_value_doc.get("key") if data_key: @@ -1091,7 +1111,7 @@ def wrap_html_with_context(html: str, context: dict) -> str: script_escaped = escape_single_quotes(context.get("block_data_script") or "") html = ( - f"{{% with block = {{}} | execute_script_and_combine('{script_escaped}', {all_props_literal}) %}}" + f"{{% with block_data = {{}} | execute_block_data_script('{script_escaped}', {all_props_literal}) %}}" f"{html}" f"{{% endwith %}}" ) diff --git a/builder/hooks.py b/builder/hooks.py index 49c80bdce..f2149e34a 100644 --- a/builder/hooks.py +++ b/builder/hooks.py @@ -57,7 +57,7 @@ jinja = { "filters": [ "builder.utils.combine", - "builder.utils.execute_script_and_combine", + "builder.utils.execute_block_data_script", "builder.utils.hash", "builder.utils.to_safe_json", ], diff --git a/builder/templates/generators/webpage.html b/builder/templates/generators/webpage.html index a85f3361f..05ad4678b 100644 --- a/builder/templates/generators/webpage.html +++ b/builder/templates/generators/webpage.html @@ -46,6 +46,22 @@ {%- if _head_html %} {{ _head_html|safe }} {%-endif -%} + + {% block page_content %} {{ __content }} diff --git a/builder/utils.py b/builder/utils.py index 8d3ed8efd..66937bc9f 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -657,7 +657,7 @@ def get_export_paths(app_path, export_name): } -def to_dict_with_fallback(obj): +def safe_dict_conversion(obj): try: return frappe._dict(obj) except TypeError: @@ -672,23 +672,72 @@ def combine(a, b): return b if b is None: return a - res = to_dict_with_fallback(a) - res.update(to_dict_with_fallback(b)) + res = safe_dict_conversion(a) + res.update(safe_dict_conversion(b)) return res def hash(s): - return f"{frappe.generate_hash(length=6)}-{s}" + return f"{frappe.generate_hash(length=6)}_{s}" def to_safe_json(data): return frappe.as_json(data or {}) -def execute_script_and_combine(prev_block_data, block_data_script, props): +class DictWithFallback(frappe._dict): + """A dictionary that falls back to another dictionary for keys that are not present in the main dictionary. + Keys are also accessible as attributes for convenience. This is used to create the block data context where the block data script can set values in the main dictionary, and access fallback values from the previous block data. + """ + + def __init__(self, primary, fallback): + """Initialize with primary dict and fallback dict + Args: + primary: The primary dictionary (where new values are set) + fallback: The fallback dictionary (used when key not found in primary) + """ + super().__init__(primary) + self._fallback = fallback + + def __getitem__(self, key): + """Get item from primary dict, fall back to fallback dict if not found""" + try: + return super().__getitem__(key) + except KeyError: + if self._fallback is not None: + return self._fallback[key] + raise + def __getattr__(self, key): + """Get attribute from primary dict, fall back to fallback dict if not found""" + try: + return super().__getitem__(key) + except KeyError: + if self._fallback is not None: + return getattr(self._fallback, key) + raise AttributeError(f"{self.__class__.__name__} object has no attribute '{key}'") + + def get(self, key, default=None): + """Get with fallback support""" + try: + return self[key] + except KeyError: + return default + + def get_self(self): + """Return the primary dictionary (self) as a regular dict without _fallback""" + res = dict(self) + del res["_fallback"] + return res + + +def execute_block_data_script(prev_block_data, block_data_script, props): props = frappe._dict(frappe.parse_json(props or "{}")) block_data = frappe._dict() - _locals = dict(block=to_dict_with_fallback(prev_block_data or {}), props=props) + block_locals = DictWithFallback(block_data, safe_dict_conversion(prev_block_data or {})) + print("Initial block data before executing script:", block_data) + _locals = dict( + block=block_locals, props=props + ) execute_script(unescape_html(block_data_script), _locals, "sample") - block_data.update(_locals["block"]) + block_data.update(_locals["block"].get_self()) return block_data From 7686c4442fe7f2f97bebc7e7bd83333c95693f1f Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Wed, 15 Apr 2026 23:47:16 +0530 Subject: [PATCH 2/3] feat: partial rendering - blocks which can be partially rendered are named Tiles (subject to change) - AlpineJS is now bundled (if opted for or if page contains Tiles) - Tiles for a page are saved upon preview or publish, NOT every time that page is saved - a Tile can be rendered by making a POST request to the same URL as the page and providing the block_id, block_data and props or simply using the refreshTile function --- .../doctype/builder_page/builder_page.json | 26 +++- .../doctype/builder_page/builder_page.py | 146 +++++++++++++----- .../builder/doctype/builder_tile/__init__.py | 0 .../doctype/builder_tile/builder_tile.json | 45 ++++++ .../doctype/builder_tile/builder_tile.py | 24 +++ builder/templates/generators/tile.html | 3 + builder/templates/generators/webpage.html | 16 -- .../templates/generators/webpage_scripts.html | 12 +- builder/utils.py | 53 +------ frontend/src/block.ts | 11 ++ frontend/src/builder.d.ts | 1 + frontend/src/components/BlockContextMenu.vue | 21 +++ frontend/src/components/BlockLayers.vue | 17 +- frontend/src/components/Settings/PageCode.vue | 10 ++ frontend/src/pages/PagePreview.vue | 5 + frontend/src/stores/pageStore.ts | 17 ++ frontend/src/types/Builder/BuilderPage.ts | 2 + 17 files changed, 302 insertions(+), 107 deletions(-) create mode 100644 builder/builder/doctype/builder_tile/__init__.py create mode 100644 builder/builder/doctype/builder_tile/builder_tile.json create mode 100644 builder/builder/doctype/builder_tile/builder_tile.py create mode 100644 builder/templates/generators/tile.html diff --git a/builder/builder/doctype/builder_page/builder_page.json b/builder/builder/doctype/builder_page/builder_page.json index e1cf3b7a0..5bc114806 100644 --- a/builder/builder/doctype/builder_page/builder_page.json +++ b/builder/builder/doctype/builder_page/builder_page.json @@ -21,11 +21,14 @@ "section_break_ujsp", "blocks", "draft_blocks", + "tiles", + "draft_tiles", "scripting_tab", "page_data_script", "head_html", "body_html", "client_scripts", + "include_alpinejs", "settings_tab", "section_break_shab", "preview", @@ -242,11 +245,32 @@ "label": "App", "mandatory_depends_on": "is_standard", "no_copy": 1 + }, + { + "fieldname": "tiles", + "fieldtype": "Table", + "label": "Tiles", + "options": "Builder Tile", + "read_only": 1 + }, + { + "default": "0", + "description": "AlpineJS is included by default in pages with Tiles.", + "fieldname": "include_alpinejs", + "fieldtype": "Check", + "label": "Include AlpineJS" + }, + { + "fieldname": "draft_tiles", + "fieldtype": "Table", + "label": "Draft Tiles", + "options": "Builder Tile", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-05 16:23:52.605250", + "modified": "2026-04-15 21:28:34.013846", "modified_by": "Administrator", "module": "Builder", "name": "Builder Page", diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 0ac9e6dc9..2b29e67a8 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -100,6 +100,7 @@ class BuilderPage(WebsiteGenerator): from builder.builder.doctype.builder_page_client_script.builder_page_client_script import ( BuilderPageClientScript, ) + from builder.builder.doctype.builder_tile.builder_tile import BuilderTile app: DF.Literal[None] authenticated_access: DF.Check @@ -109,9 +110,11 @@ class BuilderPage(WebsiteGenerator): client_scripts: DF.TableMultiSelect[BuilderPageClientScript] disable_indexing: DF.Check draft_blocks: DF.LongText | None + draft_tiles: DF.Table[BuilderTile] dynamic_route: DF.Check favicon: DF.AttachImage | None head_html: DF.Code | None + include_alpinejs: DF.Check is_standard: DF.Check is_template: DF.Check language: DF.Data | None @@ -124,6 +127,7 @@ class BuilderPage(WebsiteGenerator): project_folder: DF.Link | None published: DF.Check route: DF.Data | None + tiles: DF.Table[BuilderTile] # end: auto-generated types def onload(self): @@ -225,6 +229,7 @@ def publish(self): if self.draft_blocks: self.blocks = self.draft_blocks self.draft_blocks = None + self.draft_tiles = [] self.save() frappe.enqueue_doc( self.doctype, @@ -241,9 +246,49 @@ def unpublish(self): self.published = 0 self.save() + @frappe.whitelist() + def save_tiles(self, for_draft: bool = False): + _, _, _, _, tiles = get_block_html((self.draft_blocks if for_draft else self.blocks) or "[]") + if not for_draft: + self.tiles = [] + for tile_id, tile_block in tiles.items(): + tile_doc = self.append("tiles", {}) + tile_doc.block_id = tile_id + tile_doc.blocks = frappe.as_json(tile_block) + else: + self.draft_tiles = [] + for tile_id, tile_block in tiles.items(): + tile_doc = self.append("draft_tiles", {}) + tile_doc.block_id = tile_id + tile_doc.blocks = frappe.as_json(tile_block) + self.save() + def get_context(self, context): - # delete default favicon - del context.favicon + # Handle tile partial-rendering requests + + if frappe.form_dict and frappe.form_dict.get("render_tile"): + context["template"] = "templates/generators/tile.html" + + tiles = self.draft_tiles if getattr(frappe.local.request, "for_preview", None) else self.tiles + + block_id = frappe.form_dict.get("block_id") + block_data = frappe.parse_json(frappe.form_dict.get("block_data")) or {} + props = frappe.form_dict.get("props") or {} + + for tile in tiles: + if tile.block_id == block_id: + content, _, _, _, _ = get_block_html(tile.blocks, for_tile=True) + context.__content = content + context.update({"block_data": block_data, "props": props, "has_tile": True}) + context["__content"] = render_template(context.__content, context) + break + else: + frappe.throw(f"Tile with block_id {block_id} not found", frappe.DoesNotExistError) + return + + # Handle normal page rendering + + del context.favicon # delete default favicon context.disable_indexing = self.disable_indexing page_data = self.get_page_data() if page_data.get("title"): @@ -255,7 +300,7 @@ def get_context(self, context): if context.preview and self.draft_blocks: blocks = self.draft_blocks - content, style, fonts, has_block_script = get_block_html(blocks) + content, style, fonts, has_block_script, tiles = get_block_html(blocks) if self.dynamic_route or page_data or has_block_script: context.no_cache = 1 @@ -264,6 +309,7 @@ def get_context(self, context): context.fonts = fonts context.__content = content context.style = render_template(style, page_data) + context.has_tile = len(tiles) > 0 context.editor_link = f"/{builder_path}/page/{self.name}" if frappe.form_dict and self.dynamic_route: query_string = "&".join( @@ -281,6 +327,8 @@ def get_context(self, context): else: context.base_url = frappe.utils.get_url(self.route) + context.include_alpinejs = self.include_alpinejs + context.update(page_data) self.set_style_and_script(context) @@ -512,12 +560,13 @@ def get_block_data( return block_data -def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]: +def get_block_html(blocks: str | list, for_tile: bool = False) -> tuple[str, str, dict, bool]: """ Main entry point for converting blocks to HTML. #### Args: blocks: JSON string or list of block dictionaries + for_tile: Whether to render blocks for a tile #### Returns: Tuple of (`html_content`, `css_styles`, `font_map`, `has_block_script`) @@ -539,6 +588,7 @@ def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]: "standard_props_stack": {}, # prop_name -> list of prop_info "global_script_tag": soup.new_tag("script"), "used_block_scripts": set(), + "tile_blocks": {}, # block_id -> block json } html_parts = [] @@ -554,22 +604,36 @@ def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]: # Add global script to the top tag.insert(0, shared_state["global_script_tag"]) - html = wrap_html_with_context(str(tag), block_context) + html = wrap_html_with_context(str(tag), block_context, for_tile, block.get("blockId", "")) # Write html to a file for debugging with open("output.html", "w") as f: f.write(html) html_parts.append(html) - return "".join(html_parts), str(style_tag), font_map, shared_state["has_block_script"] + return ( + "".join(html_parts), + str(style_tag), + font_map, + shared_state["has_block_script"], + shared_state["tile_blocks"], + ) -def build_tag(block: dict, state: dict, data_key: dict | None = None) -> bs.Tag: +def build_tag( + block: dict, + state: dict, + data_key: dict | None = None, +) -> bs.Tag: """ Transforms a single block to an HTML tag. #### Returns: BeautifulSoup tag element """ + is_tile = block.get("isTile", False) + + if is_tile: + state["tile_blocks"][block.get("blockId")] = copy.deepcopy(block) props = process_block_props(block, data_key, state["standard_props_stack"]) @@ -577,12 +641,17 @@ def build_tag(block: dict, state: dict, data_key: dict | None = None) -> bs.Tag: tag = create_html_tag(block, state) + if is_tile: + tag["data-tile-id"] = block.get("blockId") + if is_repeater_block(block): render_repeater_children(tag, block, data_key, state) else: render_children(tag, block, data_key, state) attach_client_script(tag, block, state) + if is_tile: + attach_client_data(tag, block, state) # Add body scripts for body element effective_element = block.get("originalElement") or block.get("element") @@ -605,6 +674,7 @@ def get_block_context(block: dict, props: dict) -> dict: passed_down_props = {name: info["value"] for name, info in props.items() if info["is_passed_down"]} return { + "block_id": block.get("blockId"), "all_props": all_props, "passed_down_props": passed_down_props, "block_data_script": block.get("blockDataScript"), @@ -764,7 +834,7 @@ def build_tag_classes(block: dict, state: dict) -> list[str]: def generate_and_apply_styles(block: dict, state: dict) -> str: """Generate a unique style class and append all styles to the style tag.""" - style_class = f"fb-{frappe.generate_hash(length=8)}" + style_class = f"fb-{block.get('blockId')}" style_tag = state["style_tag"] font_map = state["font_map"] @@ -948,6 +1018,16 @@ def get_visibility_condition_key(block: dict, data_key: dict | None) -> str | No return key +def attach_client_data(tag: bs.Tag, block: dict, state: dict): + tag.attrs["x-data"] = f"block('{block.get('blockId')}')" + script_tag = state["soup"].new_tag("script", type="application/json") + script_tag.string = ( + '{ "block_data": {{ block_data | to_safe_json }}, "props": {{ props | to_safe_json }} }' + ) + script_tag.attrs["data-block-data"] = block.get("blockId") + tag.append(script_tag) + + def attach_client_script(tag: bs.Tag, block: dict, state: dict): """Attach client-side JavaScript to the block.""" script = block.get("blockClientScript") @@ -968,23 +1048,24 @@ def attach_client_script(tag: bs.Tag, block: dict, state: dict): # Add data attribute for selecting this specific block tag.attrs["data-block-uid"] = "{{ unique_hash }}" - tag.attrs["x-data"] = "data_{{ unique_hash }}" - - alpine_loader_code = "loadAlpineData('data_{{ unique_hash }}', {{ own_block_data | to_safe_json }});" # Add local script to call the function local_script = state["soup"].new_tag("script") + script_content = ( + f"const element = document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]');" + f"(client_script_{script_unique_id}).call(" + f" element, " + f" {{{{ props | to_safe_json }}}}, " + f" {{{{ {jinja_safe_key('block_data.block_data')} | to_safe_json }}}}" + f");" + ) local_script.string = ( - f"document.addEventListener('alpine:initialized', async () => {{" - f" const element = document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]');" - f" const block_data = Alpine.$data(element);" - f" (client_script_{script_unique_id}).call(" - f" element, " - f" {{{{ props | to_safe_json }}}}, " - f" block_data" - f" );" - f"}});" - f"{alpine_loader_code}" + f"{{% if has_tile %}}" + f"function exec() {{ {script_content} }}" + f"if (window.Alpine) {{ exec() }} else {{ document.addEventListener('alpine:initialized', exec) }}" + f"{{% else %}}" + f"{{ {script_content} }}" + f"{{% endif %}}" ) tag.append(local_script) @@ -993,7 +1074,9 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict): """Append child tag with proper Jinja context wrapping.""" # Generate unique hash for this block instance # This is unique for each block irrespective of loops or components - parent.append("{% with unique_hash = (loop.index if loop is defined else 0) | hash %}") + parent.append( + f"{{% with unique_hash = '{context.get('block_id', '')}' ~ '_' ~ (loop.index if loop is defined else 0) %}}" + ) if context.get("default_props"): props_str = ", ".join([f"'{var}': {var}" for var in context["default_props"]]) @@ -1009,9 +1092,8 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict): if context.get("block_data_script"): escaped_script = escape_single_quotes(context["block_data_script"]) parent.append( - f"{{% with own_block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}" + f"{{% with block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}" ) - parent.append("{% with block_data = own_block_data | combine(block_data) %}") if context.get("visibility_key"): parent.append(f"{{% if {context['visibility_key']} %}}") @@ -1023,7 +1105,6 @@ def append_child_with_context(parent: bs.Tag, child: bs.Tag, context: dict): if context.get("block_data_script"): parent.append("{% endwith %}") - parent.append("{% endwith %}") parent.append("{% endwith %}") parent.append("{% endwith %}") @@ -1057,8 +1138,6 @@ def set_dynamic_content_placeholders(block: dict, data_key: dict | None = None): if value_type == "attribute": current_value = block["attributes"].get(property_name, "") block["attributes"][property_name] = f"{{{{ {key} or '{escape_single_quotes(current_value)}' }}}}" - if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": - block["attributes"][f"x-bind:{property_name}"] = original_key elif value_type == "style": if not block["attributes"].get("style"): @@ -1069,18 +1148,12 @@ def set_dynamic_content_placeholders(block: dict, data_key: dict | None = None): block["attributes"]["style"] += ( f"{css_property}: {{{{ {key} or '{escape_single_quotes(current_value)}' }}}};" ) - if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": - block["attributes"]["x-bind:style"] = f"{css_property}: {original_key}" elif value_type == "key" and not block.get("isRepeaterBlock"): current_value = block.get(property_name, "") block[property_name] = ( f"{{{{ {key} if {key} or {key} in ['', 0] else '{escape_single_quotes(current_value)}' }}}}" ) - if dynamic_value_doc.get("comesFrom", "dataScript") == "blockDataScript": - block["attributes"][ - "x-text" if property_name == "innerHTML" else f"x-bind:{property_name}" - ] = original_key def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key: dict | None) -> str: @@ -1099,7 +1172,7 @@ def get_dynamic_value_key(dynamic_value_doc: dict, original_key: str, data_key: return key -def wrap_html_with_context(html: str, context: dict) -> str: +def wrap_html_with_context(html: str, context: dict, for_tile: bool = False, tile_block_id: str = "") -> str: """ Wrap HTML with Jinja context variables. @@ -1111,14 +1184,17 @@ def wrap_html_with_context(html: str, context: dict) -> str: script_escaped = escape_single_quotes(context.get("block_data_script") or "") html = ( - f"{{% with block_data = {{}} | execute_block_data_script('{script_escaped}', {all_props_literal}) %}}" + f"{{% with block_data = {'block_data' if for_tile else '{}'} %}}" + f"{{% with block_data = block_data | execute_block_data_script('{script_escaped}', {all_props_literal}) %}}" f"{html}" f"{{% endwith %}}" + f"{{% endwith %}}" ) # Set props contexts html = f"{{% with props = {all_props_literal} %}}{html}{{% endwith %}}" html = f"{{% with passed_down_props = {passed_down_literal} %}}{html}{{% endwith %}}" + html = f"{{% with unique_hash = '{tile_block_id if for_tile else 'root'}' %}}{html}{{% endwith %}}" return html diff --git a/builder/builder/doctype/builder_tile/__init__.py b/builder/builder/doctype/builder_tile/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/builder/builder/doctype/builder_tile/builder_tile.json b/builder/builder/doctype/builder_tile/builder_tile.json new file mode 100644 index 000000000..9ed1705da --- /dev/null +++ b/builder/builder/doctype/builder_tile/builder_tile.json @@ -0,0 +1,45 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-15 09:48:15.518085", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "block_id", + "blocks" + ], + "fields": [ + { + "fieldname": "block_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Block Id", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "blocks", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "Blocks", + "read_only": 1, + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-15 09:48:15.518085", + "modified_by": "Administrator", + "module": "Builder", + "name": "Builder Tile", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/builder/builder/doctype/builder_tile/builder_tile.py b/builder/builder/doctype/builder_tile/builder_tile.py new file mode 100644 index 000000000..585c0719e --- /dev/null +++ b/builder/builder/doctype/builder_tile/builder_tile.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Frappe Technologies Pvt Ltd and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BuilderTile(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + block_id: DF.Data + blocks: DF.LongText + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/builder/templates/generators/tile.html b/builder/templates/generators/tile.html new file mode 100644 index 000000000..08c9799ad --- /dev/null +++ b/builder/templates/generators/tile.html @@ -0,0 +1,3 @@ +{% block content %} + {{ __content }} +{% endblock %} \ No newline at end of file diff --git a/builder/templates/generators/webpage.html b/builder/templates/generators/webpage.html index 05ad4678b..a85f3361f 100644 --- a/builder/templates/generators/webpage.html +++ b/builder/templates/generators/webpage.html @@ -46,22 +46,6 @@ {%- if _head_html %} {{ _head_html|safe }} {%-endif -%} - - {% block page_content %} {{ __content }} diff --git a/builder/templates/generators/webpage_scripts.html b/builder/templates/generators/webpage_scripts.html index 886439125..41371adbc 100644 --- a/builder/templates/generators/webpage_scripts.html +++ b/builder/templates/generators/webpage_scripts.html @@ -1,4 +1,14 @@ {% block script %} + +{% if has_tile or include_alpinejs %} + + + +{% endif %} {%- if scripts -%} {% for script_path in scripts %} @@ -10,7 +20,7 @@ {% if enable_view_tracking and not preview %} {% endif %} {% if not preview %} diff --git a/builder/utils.py b/builder/utils.py index 66937bc9f..eafafa561 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -685,59 +685,10 @@ def to_safe_json(data): return frappe.as_json(data or {}) -class DictWithFallback(frappe._dict): - """A dictionary that falls back to another dictionary for keys that are not present in the main dictionary. - Keys are also accessible as attributes for convenience. This is used to create the block data context where the block data script can set values in the main dictionary, and access fallback values from the previous block data. - """ - - def __init__(self, primary, fallback): - """Initialize with primary dict and fallback dict - Args: - primary: The primary dictionary (where new values are set) - fallback: The fallback dictionary (used when key not found in primary) - """ - super().__init__(primary) - self._fallback = fallback - - def __getitem__(self, key): - """Get item from primary dict, fall back to fallback dict if not found""" - try: - return super().__getitem__(key) - except KeyError: - if self._fallback is not None: - return self._fallback[key] - raise - def __getattr__(self, key): - """Get attribute from primary dict, fall back to fallback dict if not found""" - try: - return super().__getitem__(key) - except KeyError: - if self._fallback is not None: - return getattr(self._fallback, key) - raise AttributeError(f"{self.__class__.__name__} object has no attribute '{key}'") - - def get(self, key, default=None): - """Get with fallback support""" - try: - return self[key] - except KeyError: - return default - - def get_self(self): - """Return the primary dictionary (self) as a regular dict without _fallback""" - res = dict(self) - del res["_fallback"] - return res - - def execute_block_data_script(prev_block_data, block_data_script, props): props = frappe._dict(frappe.parse_json(props or "{}")) block_data = frappe._dict() - block_locals = DictWithFallback(block_data, safe_dict_conversion(prev_block_data or {})) - print("Initial block data before executing script:", block_data) - _locals = dict( - block=block_locals, props=props - ) + _locals = dict(block=safe_dict_conversion(prev_block_data or {}), props=props) execute_script(unescape_html(block_data_script), _locals, "sample") - block_data.update(_locals["block"].get_self()) + block_data.update(_locals["block"]) return block_data diff --git a/frontend/src/block.ts b/frontend/src/block.ts index 2914a0bf8..0c45f869e 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -73,6 +73,7 @@ class Block implements BlockOptions { // @ts-expect-error referenceComponent: Block | null; customAttributes: BlockAttributeMap; + isTile: boolean; constructor(options: BlockOptions) { const componentStore = useComponentStore(); this.element = options.element; @@ -82,6 +83,7 @@ class Block implements BlockOptions { this.isChildOfComponent = options.isChildOfComponent; this.referenceBlockId = options.referenceBlockId; this.parentBlock = options.parentBlock || null; + this.isTile = Boolean(options.isTile); if (this.extendedFromComponent) { componentStore.loadComponent(this.extendedFromComponent); } @@ -1026,6 +1028,15 @@ class Block implements BlockOptions { setBlockProps(props: BlockProps) { this.props = props; } + getIsTile(): boolean { + if (this.isExtendedFromComponent()) { + return !!this.referenceComponent?.isTile; + } + return this.isTile; + } + setIsTile(val: boolean){ + this.isTile = val; + } } function extendWithComponent( diff --git a/frontend/src/builder.d.ts b/frontend/src/builder.d.ts index 9f9da6007..f30f2ab22 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -48,6 +48,7 @@ declare interface BlockOptions { children?: Array; dynamicValues?: Array; draggable?: boolean; + isTile?: boolean; [key: string]: any; } diff --git a/frontend/src/components/BlockContextMenu.vue b/frontend/src/components/BlockContextMenu.vue index 69a1f7a9d..6f9fa3406 100644 --- a/frontend/src/components/BlockContextMenu.vue +++ b/frontend/src/components/BlockContextMenu.vue @@ -13,6 +13,7 @@ import NewComponent from "@/components/Modals/NewComponent.vue"; import useBuilderStore from "@/stores/builderStore"; import useCanvasStore from "@/stores/canvasStore"; import useComponentStore from "@/stores/componentStore"; +import blockController from "@/utils/blockController"; import getBlockTemplate from "@/utils/blockTemplate"; import { confirm, detachBlockFromComponent, getBlockCopy, triggerCopyEvent } from "@/utils/helpers"; import { useStorage } from "@vueuse/core"; @@ -226,6 +227,26 @@ const contextMenuOptions: ContextMenuOption[] = [ condition: () => !block.value.isExtendedFromComponent() && Boolean(window.is_developer_mode), disabled: () => builderStore.readOnlyMode, }, + { + label: "Convert to Tile", + condition: () => + !blockController.multipleBlocksSelected() && + !blockController.isRoot() && + !blockController.getSelectedBlocks()[0].getIsTile(), + action: () => { + blockController.getSelectedBlocks()[0].setIsTile(true); + }, + }, + { + label: "Revert to Block", + condition: () => + !blockController.multipleBlocksSelected() && + !blockController.isRoot() && + blockController.getSelectedBlocks()[0].getIsTile(), + action: () => { + blockController.getSelectedBlocks()[0].setIsTile(false); + }, + }, { label: "Save As Component", action: () => (showNewComponentDialog.value = true), diff --git a/frontend/src/components/BlockLayers.vue b/frontend/src/components/BlockLayers.vue index 3d981ce0c..a3b100c91 100644 --- a/frontend/src/components/BlockLayers.vue +++ b/frontend/src/components/BlockLayers.vue @@ -55,7 +55,7 @@ 'text-purple-500 opacity-80 dark:opacity-100 dark:brightness-125 dark:saturate-[0.3]': element.isExtendedFromComponent(), }" - v-if="!Boolean(element.extendedFromComponent)" /> + v-if="!Boolean(element.extendedFromComponent) && !element.getIsTile()" /> + ; diff --git a/frontend/src/components/Settings/PageCode.vue b/frontend/src/components/Settings/PageCode.vue index 71ce45e83..29be5f094 100644 --- a/frontend/src/components/Settings/PageCode.vue +++ b/frontend/src/components/Settings/PageCode.vue @@ -20,6 +20,16 @@ class="shrink-0" @update:modelValue="pageStore.updateActivePage('body_html', $event)" :showLineNumbers="true"> + {% endif %} {%- if scripts -%}