diff --git a/builder/builder/doctype/builder_page/builder_page.json b/builder/builder/doctype/builder_page/builder_page.json index 763e6ae27..c0dec7947 100644 --- a/builder/builder/doctype/builder_page/builder_page.json +++ b/builder/builder/doctype/builder_page/builder_page.json @@ -23,11 +23,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", @@ -245,6 +248,27 @@ "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 + }, { "fieldname": "published_at", "fieldtype": "Datetime", diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index bbee74d1c..3bd40175e 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -40,6 +40,7 @@ get_template_assets_folder_path, is_component_used, sanitize_style_value, + script_hash, split_styles, ) @@ -102,6 +103,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 @@ -111,9 +113,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 @@ -127,6 +131,7 @@ class BuilderPage(WebsiteGenerator): published: DF.Check published_at: DF.Datetime | None route: DF.Data | None + tiles: DF.Table[BuilderTile] # end: auto-generated types def onload(self): @@ -230,6 +235,7 @@ def publish(self): if self.draft_blocks: self.blocks = self.draft_blocks self.draft_blocks = None + self.draft_tiles = [] self.save() capture("builder_page_published", "builder") frappe.enqueue_doc( @@ -248,9 +254,67 @@ def unpublish(self): self.save() capture("builder_page_unpublished", "builder") + + @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() + + @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 context.preview = getattr(getattr(frappe.local, "request", None), "for_preview", None) @@ -271,7 +335,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 @@ -280,6 +344,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( @@ -297,6 +362,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) @@ -525,12 +592,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`) @@ -552,6 +620,8 @@ 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 + "for_tile": for_tile, } html_parts = [] @@ -565,16 +635,28 @@ def get_block_html(blocks: str | list) -> tuple[str, str, dict, bool]: tag = build_tag(block, shared_state) # Add global script to the top - tag.insert(0, shared_state["global_script_tag"]) + if not for_tile: + 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, ancestor_font: str | None = None + block: dict, + state: dict, + data_key: dict | None = None, ) -> bs.Tag: """ Transforms a single block to an HTML tag. @@ -582,6 +664,10 @@ def build_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"]) @@ -603,12 +689,17 @@ def build_tag( tag = create_html_tag(block, state, ancestor_font=resolved_font) + if is_tile: + tag["data-tile-id"] = block.get("blockId") + if is_repeater_block(block): render_repeater_children(tag, block, data_key, state, ancestor_font=resolved_font) else: render_children(tag, block, data_key, state, ancestor_font=resolved_font) 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") @@ -702,7 +793,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 @@ -791,7 +882,7 @@ def build_tag_classes(block: dict, state: dict, ancestor_font: str | None = None def generate_and_apply_styles(block: dict, state: dict, ancestor_font: str | None = None) -> 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"] @@ -914,8 +1005,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, } @@ -972,13 +1063,23 @@ 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}" 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") @@ -988,36 +1089,48 @@ def attach_client_script(tag: bs.Tag, block: dict, state: dict): # Generate unique identifier for the script script_unique_id = block.get("blockId") if block.get("isBlockClientScriptOverridden"): - script_unique_id = frappe.generate_hash(length=8) + script_unique_id = script_hash(script, length=8) - # Add global function definition (only once) - if script_unique_id not in state["used_block_scripts"]: - state["global_script_tag"].append( - f"function client_script_{script_unique_id}(props, block_data) {{{script}}}\n" - ) - state["used_block_scripts"].add(script_unique_id) + if not state["for_tile"]: + # Add global function definition (only once) + if script_unique_id not in state["used_block_scripts"]: + state["global_script_tag"].append( + f"function client_script_{script_unique_id}(props, block_data) {{{script}}}\n" + ) + state["used_block_scripts"].add(script_unique_id) # Add data attribute for selecting this specific block tag.attrs["data-block-uid"] = "{{ unique_hash }}" # Add local script to call the function local_script = state["soup"].new_tag("script") - local_script.string = ( + local_script["defer"] = "defer" + script_content = ( + f"const element = document.querySelector('[data-block-uid=\"{{{{ unique_hash }}}}\"]');" 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" element, " + f" {{{{ props | to_safe_json }}}}, " + f" {{{{ {jinja_safe_key('block_data.block_data')} | to_safe_json }}}}" f");" ) - tag.append(local_script) + local_script.string = ( + f"{{% if has_tile or include_alpinejs %}}" + f"function exec() {{ {script_content} }}" + f"if (window.Alpine) {{ exec() }} else {{ document.addEventListener('alpine:initialized', exec) }}" + f"{{% else %}}" + f"{{ {script_content} }}" + f"{{% endif %}}" + ) + tag.insert(0, local_script) 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 block_id = '{context['block_id']}' %}}") + 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"]]) @@ -1033,7 +1146,7 @@ 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, block_id) %}}" + f"{{% with block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}" ) if context.get("visibility_key"): @@ -1105,7 +1218,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: @@ -1114,7 +1227,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. @@ -1126,14 +1239,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 = {{}} | execute_script_and_combine('{script_escaped}', {all_props_literal}, 'root') %}}" + 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/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/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_scripts.html b/builder/templates/generators/webpage_scripts.html index 0739dd3b0..e42174bfe 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 82ae61ef5..d993bc914 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -1,3 +1,4 @@ +import hashlib import inspect import os import re @@ -680,7 +681,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: @@ -695,24 +696,28 @@ 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 script_hash(js_code, length=8): + return hashlib.sha256(js_code.encode()).hexdigest()[:length] def to_safe_json(data): return frappe.as_json(data or {}) -def execute_script_and_combine(prev_block_data, block_data_script, props, block_id): +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) - execute_script(unescape_html(block_data_script), _locals, f"block_script_for_{block_id}") + _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"]) return combine(prev_block_data, block_data) diff --git a/frontend/src/block.ts b/frontend/src/block.ts index 7d8c22362..e11164f40 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -74,6 +74,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; @@ -83,6 +84,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); } @@ -1033,6 +1035,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 f8c07c314..df9cc7fe7 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -53,6 +53,7 @@ declare interface BlockOptions { children?: Array; dynamicValues?: Array; draggable?: boolean; + isTile?: boolean; editorConfig?: BlockEditorConfig; [key: string]: any; } diff --git a/frontend/src/components/BlockContextMenu.vue b/frontend/src/components/BlockContextMenu.vue index d4e2ce3e2..75da8a09f 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"; @@ -259,6 +260,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 380fe4cb3..4223ccf9c 100644 --- a/frontend/src/components/BlockLayers.vue +++ b/frontend/src/components/BlockLayers.vue @@ -64,7 +64,7 @@ }, ]" aria-hidden="true" - v-if="!Boolean(element.extendedFromComponent) && !showCodeIcon(element)" /> + v-if="!Boolean(element.extendedFromComponent) && !showCodeIcon(element) && !element.getIsTile()" />