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 dc440073d..73fe52dfc 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"), @@ -675,7 +745,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 @@ -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"] @@ -883,8 +953,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,13 +1011,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") @@ -971,21 +1051,33 @@ def attach_client_script(tag: bs.Tag, block: dict, state: dict): # Add local script to call the function local_script = state["soup"].new_tag("script") - local_script.string = ( + local_script['defer'] = True + 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 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"]]) @@ -1000,7 +1092,9 @@ 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 block_data = block_data | execute_block_data_script('{escaped_script}', props) %}}" + ) if context.get("visibility_key"): parent.append(f"{{% if {context['visibility_key']} %}}") @@ -1070,7 +1164,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: @@ -1079,7 +1173,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. @@ -1091,14 +1185,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}) %}}" + 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 886439125..daf3b55ab 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 8d3ed8efd..eafafa561 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,23 @@ 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): +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) + _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 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"> +