From 5ad3c838d8bd90c760fbc716cf0eaf20c12725c4 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 4 Sep 2025 13:01:12 +0530 Subject: [PATCH 001/547] fix: Remove extra spaces and variables formatting --- builder/templates/generators/webpage.html | 48 +++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/builder/templates/generators/webpage.html b/builder/templates/generators/webpage.html index c4a174b47..b5973693b 100644 --- a/builder/templates/generators/webpage.html +++ b/builder/templates/generators/webpage.html @@ -23,35 +23,25 @@ {% for (font, options) in fonts.items() %}{% endfor %} {{ style }} {%- if css_variables -%} - {%- endif -%} {%- if custom_fonts -%} From 46c8697ba8b8e62d66c4b2beece5f00d41fe0711 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 5 Sep 2025 00:51:15 +0530 Subject: [PATCH 002/547] feat: [Init]System to export & import standard Builder Page --- builder/api.py | 74 ++++++- .../doctype/builder_page/builder_page.json | 25 ++- .../doctype/builder_page/builder_page.py | 189 ++++++++++++++++-- builder/utils.py | 187 ++++++++++++++++- frontend/components.d.ts | 2 + frontend/src/components/MainMenu.vue | 17 +- .../src/components/Modals/ExportPageModal.vue | 151 ++++++++++++++ .../Modals/ImportStandardPageModal.vue | 165 +++++++++++++++ .../src/components/Modals/VariableManager.vue | 1 - frontend/src/pages/PageBuilderDashboard.vue | 34 ++-- frontend/src/stores/pageStore.ts | 26 +++ frontend/src/types/Builder/BuilderPage.ts | 6 +- 12 files changed, 819 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/Modals/ExportPageModal.vue create mode 100644 frontend/src/components/Modals/ImportStandardPageModal.vue diff --git a/builder/api.py b/builder/api.py index 8cb7c8820..df9fd6abb 100644 --- a/builder/api.py +++ b/builder/api.py @@ -95,7 +95,7 @@ def upload_builder_asset(): image_file = upload_file() if image_file.file_url.endswith((".png", ".jpeg", ".jpg")) and frappe.get_cached_value( - "Builder Settings", None, "auto_convert_images_to_webp" + "Builder Settings", "Builder Settings", "auto_convert_images_to_webp" ): convert_to_webp(file_doc=image_file) return image_file @@ -171,6 +171,7 @@ def handle_image_from_url(image_url): return update_file_doc_with_webp(file_doc, image, extn) return file_doc.file_url + image_url = image_url or "" if image_url.startswith("/files"): image, filename, extn = get_local_image(image_url) if can_convert_image(extn): @@ -297,3 +298,74 @@ def get_overall_analytics( to_date=to_date, route_filter_type=route_filter_type, ) + + +@frappe.whitelist() +@redis_cache() +def get_apps_for_export(): + """Get list of apps where standard pages can be exported""" + installed_apps = frappe.get_all_apps() + apps = [] + for app in installed_apps: + try: + app_hooks = frappe.get_hooks(app_name=app) + apps.append( + { + "name": app, + "title": app_hooks.get("app_title", [app])[0], + } + ) + except Exception: + pass + return apps + + +@frappe.whitelist() +def export_page_as_standard(page_name, target_app="builder", export_name=None): + from builder.builder.doctype.builder_page.builder_page import export_page_as_standard + + return export_page_as_standard(page_name, target_app, export_name) + + +@frappe.whitelist() +def duplicate_standard_page(app_name, page_folder_name, new_page_name=None): + from builder.builder.doctype.builder_page.builder_page import duplicate_standard_page + + return duplicate_standard_page(app_name, page_folder_name, new_page_name) + + +@frappe.whitelist() +def get_standard_pages(app_name): + import os + + app_path = frappe.get_app_path(app_name) + if not app_path: + return [] + + pages_path = os.path.join(app_path, "builder_files", "pages") + if not os.path.exists(pages_path): + return [] + + standard_pages = [] + for folder_name in os.listdir(pages_path): + folder_path = os.path.join(pages_path, folder_name) + config_path = os.path.join(folder_path, "config.json") + if os.path.isdir(folder_path) and os.path.exists(config_path): + try: + with open(config_path) as f: + config = json.load(f) + + standard_pages.append( + { + "folder_name": folder_name, + "page_name": config.get("page_name", folder_name), + "page_title": config.get("page_title", folder_name), + "route": config.get("route"), + "modified": config.get("modified"), + } + ) + except Exception: + # Skip invalid config files + continue + + return standard_pages diff --git a/builder/builder/doctype/builder_page/builder_page.json b/builder/builder/doctype/builder_page/builder_page.json index 0032f02f6..d9224d386 100644 --- a/builder/builder/doctype/builder_page/builder_page.json +++ b/builder/builder/doctype/builder_page/builder_page.json @@ -15,8 +15,9 @@ "route", "dynamic_route", "column_break_ymjg", + "is_standard", "is_template", - "template_name", + "display_name", "section_break_ujsp", "blocks", "draft_blocks", @@ -169,13 +170,6 @@ "fieldtype": "Check", "label": "Is Template" }, - { - "depends_on": "is_template", - "fieldname": "template_name", - "fieldtype": "Data", - "label": "Template Name", - "mandatory_depends_on": "is_template" - }, { "fieldname": "section_break_shab", "fieldtype": "Section Break" @@ -232,11 +226,24 @@ "fieldtype": "Code", "label": "Body HTML", "options": "HTML" + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" + }, + { + "depends_on": "eval:doc.is_template || doc.is_standard", + "fieldname": "display_name", + "fieldtype": "Data", + "label": "Display Name", + "mandatory_depends_on": "eval:doc.is_template || doc.is_standard" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-20 12:22:40.697232", + "modified": "2025-08-25 15:30:29.945954", "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 bfea5880e..ea083507d 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -24,12 +24,19 @@ from builder.hooks import builder_path from builder.html_preview_image import generate_preview from builder.utils import ( + Block, ColonRule, camel_case_to_kebab_case, clean_data, + copy_asset_file, + copy_assets_from_blocks, copy_img_to_asset_folder, + create_export_directories, escape_single_quotes, execute_script, + export_client_scripts, + export_components, + extract_components_from_blocks, get_builder_page_preview_file_paths, get_template_assets_folder_path, is_component_used, @@ -72,11 +79,11 @@ def set_canonical_url(self): return context = getattr(self, "context", frappe._dict()) if self.doc.is_home_page(): - context.canonical_url = frappe.utils.get_url() + context["canonical_url"] = frappe.utils.get_url() elif self.doc.canonical_url: - context.canonical_url = render_template(self.doc.canonical_url, context) + context["canonical_url"] = render_template(self.doc.canonical_url, context) else: - context.canonical_url = frappe.utils.get_url(self.path) + context["canonical_url"] = frappe.utils.get_url(self.path) self.context = context def set_missing_values(self): @@ -102,10 +109,12 @@ class BuilderPage(WebsiteGenerator): canonical_url: DF.Data | None client_scripts: DF.TableMultiSelect[BuilderPageClientScript] disable_indexing: DF.Check + display_name: DF.Data | None draft_blocks: DF.JSON | None dynamic_route: DF.Check favicon: DF.AttachImage | None head_html: DF.Code | None + is_standard: DF.Check is_template: DF.Check meta_description: DF.SmallText | None meta_image: DF.AttachImage | None @@ -116,7 +125,6 @@ class BuilderPage(WebsiteGenerator): project_folder: DF.Link | None published: DF.Check route: DF.Data | None - template_name: DF.Data | None # end: auto-generated types def onload(self): @@ -140,7 +148,7 @@ def before_insert(self): def process_blocks(self): for block_type in ["blocks", "draft_blocks"]: if isinstance(getattr(self, block_type), list): - setattr(self, block_type, frappe.as_json(getattr(self, block_type), indent=None)) + setattr(self, block_type, frappe.as_json(getattr(self, block_type), indent=0)) if not self.blocks: self.blocks = "[]" @@ -160,7 +168,7 @@ def set_default_values(self): def on_update(self): if self.has_value_changed("route"): - if ":" in self.route or "<" in self.route: + if self.route and (":" in self.route or "<" in self.route): self.db_set("dynamic_route", 1) else: self.db_set("dynamic_route", 0) @@ -176,8 +184,8 @@ def on_update(self): if self.has_value_changed("published") and not self.published: # if this is homepage then clear homepage from builder settings - if frappe.get_cached_value("Builder Settings", None, "home_page") == self.route: - frappe.db.set_value("Builder Settings", None, "home_page", None) + if frappe.get_cached_value("Builder Settings", "Builder Settings", "home_page") == self.route: + frappe.db.set_value("Builder Settings", "Builder Settings", "home_page", None) if frappe.conf.developer_mode and self.is_template: save_as_template(self) @@ -190,7 +198,7 @@ def clear_route_cache(self): def on_trash(self): if self.is_template and frappe.conf.developer_mode: page_template_folder = os.path.join( - frappe.get_app_path("builder"), "builder", "builder_page_template", scrub(self.name) + frappe.get_app_path("builder"), "builder", "builder_page_template", scrub(str(self.name)) ) if os.path.exists(page_template_folder): shutil.rmtree(page_template_folder) @@ -212,7 +220,8 @@ def add_comment(self, comment_type="Comment", text=None, comment_email=None, com @frappe.whitelist() def publish(self, route_variables=None): if route_variables: - frappe.form_dict.update(frappe.parse_json(route_variables or "{}")) + for k, v in frappe.parse_json(route_variables or "{}").items(): + frappe.form_dict[k] = v self.published = 1 if self.draft_blocks: self.blocks = self.draft_blocks @@ -305,13 +314,15 @@ def set_favicon(self, context): if not context.get("favicon"): context.favicon = self.favicon if not context.get("favicon"): - context.favicon = frappe.get_cached_value("Builder Settings", None, "favicon") + context.favicon = frappe.get_cached_value("Builder Settings", "Builder Settings", "favicon") def set_language(self, context): # Set page-specific language or fall back to default language from Builder Settings context.language = self.language if not context.language: - context.default_language = frappe.get_cached_value("Builder Settings", None, "default_language") or "en" + context.default_language = ( + frappe.get_cached_value("Builder Settings", "Builder Settings", "default_language") or "en" + ) def is_component_used(self, component_id): if self.blocks and is_component_used(self.blocks, component_id): @@ -320,7 +331,8 @@ def is_component_used(self, component_id): return True def set_style_and_script(self, context): - for script in self.get("client_scripts", []): + client_scripts = self.get("client_scripts") or [] + for script in client_scripts: script_doc = frappe.get_cached_doc("Builder Client Script", script.builder_script) if script_doc.script_type == "JavaScript": context.setdefault("scripts", []).append(script_doc.public_url) @@ -355,7 +367,7 @@ def set_style_and_script(self, context): @frappe.whitelist() def get_page_data(self, route_variables=None): if route_variables: - frappe.form_dict.update(frappe.parse_json(route_variables or "{}")) + frappe.form_dict.update(dict(frappe.parse_json(route_variables or "{}").items())) page_data = frappe._dict() if self.page_data_script: _locals = dict(data=frappe._dict()) @@ -405,7 +417,7 @@ def replace_component(self, target_component, replace_with): def is_home_page(self): """Check if this page is set as the home page in Builder Settings.""" - return frappe.get_cached_value("Builder Settings", None, "home_page") == self.route + return frappe.get_cached_value("Builder Settings", "Builder Settings", "home_page") == self.route def get_css_variables(): @@ -424,7 +436,7 @@ def get_css_variables(): return css_variables, dark_mode_css_variables -def replace_component_in_blocks(blocks, target_component, replace_with): +def replace_component_in_blocks(blocks, target_component, replace_with) -> list[dict]: for target_block in blocks: if target_block.get("extendedFromComponent") == target_component: new_component_block = frappe.parse_json( @@ -449,12 +461,12 @@ def save_as_template(page_doc: BuilderPage): if not page_doc.template_name: page_doc.template_name = page_doc.page_title - blocks = frappe.parse_json(page_doc.blocks) + blocks: list[Block] = frappe.parse_json(page_doc.blocks or "[]") # type: ignore for block in blocks: copy_img_to_asset_folder(block, page_doc) page_doc.db_set("draft_blocks", None) - page_doc.db_set("blocks", frappe.as_json(blocks, indent=None)) + page_doc.db_set("blocks", frappe.as_json(blocks, indent=0)) page_doc.reload() export_to_files( record_list=[["Builder Page", page_doc.name, "builder_page_template"]], record_module="builder" @@ -748,7 +760,7 @@ def extend_block(block, overridden_block): return block -def set_dynamic_content_placeholder(block, data_key=False): +def set_dynamic_content_placeholder(block, data_key=None): block_data_key = block.get("dataKey", {}) or {} dynamic_values = [block_data_key] if block_data_key else [] dynamic_values += block.get("dynamicValues", []) or [] @@ -761,7 +773,7 @@ def set_dynamic_content_placeholder(block, data_key=False): if data_key: # convert a.b to (a or {}).get('b', {}) # to avoid undefined error in jinja - keys = key.split(".") + keys = (key or "").split(".") key = f"({keys[0]} or {{}})" for k in keys[1:]: key = f"{key}.get('{k}', {{}})" @@ -794,7 +806,7 @@ def find_page_with_path(route): @redis_cache(ttl=60 * 60) -def get_web_pages_with_dynamic_routes() -> dict[str, str]: +def get_web_pages_with_dynamic_routes() -> list[BuilderPage]: return frappe.get_all( "Builder Page", fields=["name", "route", "modified"], @@ -850,3 +862,138 @@ def reset_block(block): block["classes"] = [] block["dataKey"] = {} return block + + +@frappe.whitelist() +def export_page_as_standard(page_name, target_app="builder", export_name=None): + """Export a builder page as standard files to the specified app""" + import json + + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw("You do not have permission to export pages.") + + page_doc = frappe.get_doc("Builder Page", page_name) + + if not export_name: + export_name = page_doc.page_name or page_name + + # Clean the export name to be filesystem-safe + export_name = frappe.scrub(export_name) + + # Get app path + app_path = frappe.get_app_path(target_app) + if not app_path: + frappe.throw(f"App '{target_app}' not found") + + # Create directories and get paths + paths = create_export_directories(app_path, export_name) + + # Export all fields except child tables + page_config = {} + for field in page_doc.meta.fields: + if field.fieldtype not in ("Table", "Table MultiSelect"): + value = page_doc.get(field.fieldname) + if field.fieldtype in ("Datetime", "Date") and value is not None: + value = str(value) + page_config[field.fieldname] = value + + # Add some standard metadata fields + for key in ["creation", "modified", "owner", "modified_by"]: + value = getattr(page_doc, key, None) + if key in ["creation", "modified"] and value is not None: + value = str(value) + page_config[key] = value + + config_file_path = os.path.join(paths["page_path"], "config.json") + + blocks = frappe.parse_json(page_config["blocks"]) + if blocks: + copy_assets_from_blocks(blocks, paths["assets_path"]) + page_config["blocks"] = blocks + + if page_doc.favicon: + page_config["favicon"] = copy_asset_file(page_doc.favicon, paths["assets_path"]) + if page_doc.meta_image: + page_config["meta_image"] = copy_asset_file(page_doc.meta_image, paths["assets_path"]) + + with open(config_file_path, "w") as f: + json.dump(page_config, f, indent=2) + + # # Export client scripts + # export_client_scripts(page_doc, paths["client_scripts_path"]) + + # # Export components used in the page + # if blocks: + # components = extract_components_from_blocks(blocks) + # export_components(components, paths["components_path"]) + + return { + "success": True, + "message": f"Page exported successfully to {target_app}", + "export_path": paths["page_path"], + } + + +@frappe.whitelist() +def duplicate_standard_page(app_name, page_folder_name, new_page_name=None): + """Duplicate a standard page into a new builder page""" + import json + + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw("You do not have permission to create pages.") + + # Get the standard page config + app_path = frappe.get_app_path(app_name) + if not app_path: + frappe.throw(f"App '{app_name}' not found") + + config_path = os.path.join(app_path, "builder_files", "pages", page_folder_name, "config.json") + + if not os.path.exists(config_path): + frappe.throw(f"Standard page '{page_folder_name}' not found in app '{app_name}'") + + with open(config_path) as f: + config = json.load(f) + + # Create new builder page + new_page = frappe.new_doc("Builder Page") + new_page.page_name = new_page_name or f"{config.get('page_name', page_folder_name)} Copy" + new_page.page_title = config.get("page_title", new_page.page_name) + new_page.blocks = json.dumps(config.get("blocks", [])) + new_page.page_data_script = config.get("page_data_script") + new_page.head_html = config.get("head_html") + new_page.body_html = config.get("body_html") + new_page.dynamic_route = config.get("dynamic_route", 0) + new_page.disable_indexing = config.get("disable_indexing", 0) + new_page.authenticated_access = config.get("authenticated_access", 0) + new_page.meta_description = config.get("meta_description") + new_page.meta_image = config.get("meta_image") + new_page.canonical_url = config.get("canonical_url") + new_page.favicon = config.get("favicon") + new_page.published = 0 # Start as unpublished + + # Import client scripts + client_scripts_path = os.path.join(app_path, "builder_files", "client_scripts") + if os.path.exists(client_scripts_path): + for script_file in os.listdir(client_scripts_path): + if script_file.endswith(".json"): + script_path = os.path.join(client_scripts_path, script_file) + with open(script_path) as f: + script_config = json.load(f) + + # Create new client script + client_script = frappe.new_doc("Builder Client Script") + client_script.script_name = script_config.get("script_name") + client_script.script_type = script_config.get("script_type", "JavaScript") + client_script.script = script_config.get("script") + client_script.insert(ignore_permissions=True) + + # Link to page + new_page.append("client_scripts", {"builder_script": client_script.name}) + + # Import components (this would require the components to be available in the system) + # For now, we'll just save the page and let the user handle missing components + + new_page.insert(ignore_permissions=True) + + return {"success": True, "message": "Standard page duplicated successfully", "page_name": new_page.name} diff --git a/builder/utils.py b/builder/utils.py index 51ba5ebaf..a3f71b22b 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -1,10 +1,10 @@ import glob import inspect +import json import os import re import shutil import socket -import subprocess from dataclasses import dataclass from os.path import join from urllib.parse import unquote, urlparse @@ -35,13 +35,15 @@ class BlockDataKey: class Block: blockId: str = "" - children: list["Block"] = None - baseStyles: dict = None - rawStyles: dict = None - mobileStyles: dict = None - tabletStyles: dict = None - attributes: dict = None - classes: list[str] = None + from typing import ClassVar + + children: ClassVar[list["Block"]] = [] + baseStyles: ClassVar[dict] = {} + rawStyles: ClassVar[dict] = {} + mobileStyles: ClassVar[dict] = {} + tabletStyles: ClassVar[dict] = {} + attributes: ClassVar[dict] = {} + classes: ClassVar[list[str]] = [] dataKey: BlockDataKey | None = None blockName: str | None = None element: str | None = None @@ -105,7 +107,7 @@ def get_cached_doc_as_dict(doctype, name): def make_safe_get_request(url, **kwargs): parsed = urlparse(url) parsed_ip = socket.gethostbyname(parsed.hostname) - if parsed_ip.startswith("127", "10", "192", "172"): + if parsed_ip.startswith(("127", "10", "192", "172")): return return frappe.integrations.utils.make_get_request(url, **kwargs) @@ -286,7 +288,7 @@ def copy_img_to_asset_folder(block: Block, page_doc): # copy physical file to new location assets_folder_path = get_template_assets_folder_path(page_doc) shutil.copy(_file.get_full_path(), assets_folder_path) - block["attributes"]["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" + block.get("attributes", {})["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" for child in block.get("children", []) or []: copy_img_to_asset_folder(child, page_doc) @@ -407,3 +409,168 @@ def split_styles(styles): "regular": {k: v for k, v in styles.items() if ":" not in k}, "state": {k: v for k, v in styles.items() if ":" in k}, } + + +def copy_assets_from_blocks(blocks, assets_path): + if not isinstance(blocks, list): + blocks = [blocks] + + for block in blocks: + if isinstance(block, dict): + process_block_assets(block, assets_path) + children = block.get("children") + if children and isinstance(children, list): + copy_assets_from_blocks(children, assets_path) + + +def process_block_assets(block, assets_path): + """Process assets for a single block""" + if block.get("element") in ("img", "video"): + src = block.get("attributes", {}).get("src") + if src: + new_location = copy_asset_file(src, assets_path) + if new_location: + block["attributes"]["src"] = new_location + + +def copy_asset_file(file_url, assets_path): + """Copy a file from the source to assets directory and return new public path""" + if not file_url or not isinstance(file_url, str): + return None + + try: + if file_url.startswith("/files/"): + return copy_from_site_files(file_url, assets_path) + elif file_url.startswith("/builder_assets/"): + return copy_from_builder_assets(file_url, assets_path) + except Exception as e: + frappe.log_error(f"Failed to copy asset {file_url}: {e!s}") + return None + + +def copy_from_site_files(file_url, assets_path): + """Copy file from site files directory""" + source_path = os.path.join(frappe.local.site_path, "public", file_url.lstrip("/")) + if os.path.exists(source_path): + return copy_file_to_assets(source_path, file_url, assets_path) + return None + + +def copy_from_builder_assets(file_url, assets_path): + """Copy file from builder assets directory""" + source_path = os.path.join(frappe.get_app_path("builder"), "www", file_url.lstrip("/")) + if os.path.exists(source_path): + return copy_file_to_assets(source_path, file_url, assets_path) + return None + + +def copy_file_to_assets(source_path, file_url, assets_path): + """Copy file to assets directory and return public path""" + filename = os.path.basename(file_url) + dest_path = os.path.join(assets_path, filename) + shutil.copy2(source_path, dest_path) + return f"/builder_assets/page_{os.path.basename(assets_path)}/{filename}" + + +def extract_components_from_blocks(blocks): + """Extract component IDs from blocks recursively""" + components = set() + if not isinstance(blocks, list): + blocks = [blocks] + + for block in blocks: + if isinstance(block, dict): + if block.get("extendedFromComponent"): + components.add(block["extendedFromComponent"]) + children = block.get("children") + if children and isinstance(children, list): + components.update(extract_components_from_blocks(children)) + + return components + + +def export_client_scripts(page_doc, client_scripts_path): + """Export client scripts for a page""" + for script_row in page_doc.client_scripts: + script_doc = frappe.get_doc("Builder Client Script", script_row.builder_script) + script_config = { + "script_name": script_doc.name, + "script_type": script_doc.script_type, + "script": script_doc.script, + } + script_file_path = os.path.join(client_scripts_path, f"{frappe.scrub(str(script_doc.name))}.json") + + with open(script_file_path, "w") as f: + json.dump(script_config, f, indent=2) + + +def export_components(components, components_path): + """Export components to files""" + for component_id in components: + try: + component_doc = frappe.get_doc("Builder Component", component_id) + component_config = create_component_config(component_doc) + component_file_path = os.path.join( + components_path, f"{frappe.scrub(component_doc.component_name)}.json" + ) + + with open(component_file_path, "w") as f: + json.dump(component_config, f, indent=2) + except Exception as e: + frappe.log_error(f"Failed to export component {component_id}: {e!s}") + + +def create_component_config(component_doc): + """Create configuration dictionary for a component""" + return { + "component_name": component_doc.component_name, + "block": frappe.parse_json(component_doc.block or "{}"), + "preview": component_doc.preview, + "for_web_page": component_doc.for_web_page, + "creation": str(component_doc.creation), + "modified": str(component_doc.modified), + "owner": component_doc.owner, + "modified_by": component_doc.modified_by, + } + + +def create_export_directories(app_path, export_name): + """Create necessary directories for export and return paths""" + paths = get_export_paths(app_path, export_name) + setup_assets_symlink(app_path, paths["assets_path"]) + for path in paths.values(): + os.makedirs(path, exist_ok=True) + + return paths + + +def get_export_paths(app_path, export_name): + """Get all export directory paths""" + builder_files_path = os.path.join(app_path, "builder_files") + pages_path = os.path.join(builder_files_path, "pages") + + return { + "page_path": os.path.join(pages_path, export_name), + "assets_path": os.path.join(builder_files_path, "assets"), + "client_scripts_path": os.path.join(builder_files_path, "client_scripts"), + "components_path": os.path.join(builder_files_path, "components"), + "builder_files_path": builder_files_path, + "pages_path": pages_path, + } + + +def setup_assets_symlink(app_path, assets_path): + symlink_path = os.path.join(app_path, "www", "builder_assets", "page_assets") + os.makedirs(os.path.dirname(symlink_path), exist_ok=True) + os.makedirs(assets_path, exist_ok=True) + if not os.path.islink(symlink_path): + remove_existing_path(symlink_path) + os.symlink(assets_path, symlink_path) + + +def remove_existing_path(path): + if os.path.exists(path): + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 000cf47a3..97284c825 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -55,6 +55,7 @@ declare module 'vue' { DraggablePopup: typeof import('./src/components/Controls/DraggablePopup.vue')['default'] DynamicValueHandler: typeof import('./src/components/Controls/DynamicValueHandler.vue')['default'] EditableSpan: typeof import('./src/components/EditableSpan.vue')['default'] + ExportPageModal: typeof import('./src/components/Modals/ExportPageModal.vue')['default'] EyeDropper: typeof import('./src/components/Icons/EyeDropper.vue')['default'] Files: typeof import('./src/components/Icons/Files.vue')['default'] FitScreen: typeof import('./src/components/Icons/FitScreen.vue')['default'] @@ -68,6 +69,7 @@ declare module 'vue' { Globe: typeof import('./src/components/Icons/Globe.vue')['default'] ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default'] ImageUploadInput: typeof import('./src/components/ImageUploadInput.vue')['default'] + ImportStandardPageModal: typeof import('./src/components/Modals/ImportStandardPageModal.vue')['default'] InlineInput: typeof import('./src/components/Controls/InlineInput.vue')['default'] Input: typeof import('./src/components/Controls/Input.vue')['default'] InputLabel: typeof import('./src/components/Controls/InputLabel.vue')['default'] diff --git a/frontend/src/components/MainMenu.vue b/frontend/src/components/MainMenu.vue index a25b9b6ad..552dce1f3 100644 --- a/frontend/src/components/MainMenu.vue +++ b/frontend/src/components/MainMenu.vue @@ -28,12 +28,18 @@ onClick: () => pageStore.duplicatePage(pageStore.activePage as BuilderPage), icon: 'copy', }, + { + label: 'Export as Standard', + onClick: () => (showExportModal = true), + icon: 'download', + condition: () => Boolean(pageStore.activePage), + }, { label: `Toggle Theme`, onClick: () => toggleDark(), icon: isDark ? 'sun' : 'moon', }, - { label: 'Settings', onClick: () => $emit('showSettings'), icon: 'settings' }, + { label: 'Settings', onClick: () => emit('showSettings'), icon: 'settings' }, { label: 'Help', @@ -74,6 +80,9 @@ + + + diff --git a/frontend/src/components/Modals/ImportStandardPageModal.vue b/frontend/src/components/Modals/ImportStandardPageModal.vue new file mode 100644 index 000000000..a4bfd2622 --- /dev/null +++ b/frontend/src/components/Modals/ImportStandardPageModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/frontend/src/components/Modals/VariableManager.vue b/frontend/src/components/Modals/VariableManager.vue index dcc49db69..31f558804 100644 --- a/frontend/src/components/Modals/VariableManager.vue +++ b/frontend/src/components/Modals/VariableManager.vue @@ -319,7 +319,6 @@ const saveVariable = async (row: ListViewRow) => { }; const deleteVariableRow = async (row: ListViewRow) => { - console.log("delete", row); if (row.isNew) { newVariable.value = null; return; diff --git a/frontend/src/pages/PageBuilderDashboard.vue b/frontend/src/pages/PageBuilderDashboard.vue index 9650c1ae3..f5da2d9c8 100644 --- a/frontend/src/pages/PageBuilderDashboard.vue +++ b/frontend/src/pages/PageBuilderDashboard.vue @@ -6,20 +6,25 @@
- - - New +
+ + Import Standard - + + + New + + +
@@ -154,11 +159,13 @@ v-model="showFolderSelectorDialog" :currentFolder="builderStore.activeFolder" @folderSelected="setFolder"> + diff --git a/frontend/src/components/Settings/PageGeneral.vue b/frontend/src/components/Settings/PageGeneral.vue index 81421bec7..ac32aa3e5 100644 --- a/frontend/src/components/Settings/PageGeneral.vue +++ b/frontend/src/components/Settings/PageGeneral.vue @@ -127,6 +127,31 @@ description="Prevent search engines from indexing this page" :modelValue="Boolean(pageStore.activePage?.disable_indexing)" @update:modelValue="(val: Boolean) => pageStore.updateActivePage('disable_indexing', val)" /> +
@@ -155,6 +180,7 @@ import Switch from "@/components/Controls/Switch.vue"; import AuthenticatedUserIcon from "@/components/Icons/AuthenticatedUser.vue"; import builderProjectFolder from "@/data/builderProjectFolder"; import { builderSettings } from "@/data/builderSettings"; +import moduleDef from "@/data/moduleDef"; import useBuilderStore from "@/stores/builderStore"; import usePageStore from "@/stores/pageStore"; import { BuilderProjectFolder } from "@/types/Builder/BuilderProjectFolder"; @@ -163,6 +189,7 @@ import { computed } from "vue"; const pageStore = usePageStore(); const builderStore = useBuilderStore(); +const isDeveloperMode = computed(() => Boolean(window.is_developer_mode)); const fullURL = computed( () => window.location.origin + (pageStore.activePage?.route ? "/" + pageStore.activePage.route : ""), ); @@ -182,4 +209,21 @@ const folderOptions = computed(() => { return [homeOption, ...options]; }); + +const moduleOptions = computed(() => { + const defaultOption = { + label: "Select Module", + value: "", + }; + + const options = + moduleDef.data?.map((module: { name: string; module_name: string }) => { + return { + label: module.module_name, + value: module.name, + }; + }) || []; + + return [defaultOption, ...options]; +}); diff --git a/frontend/src/data/moduleDef.ts b/frontend/src/data/moduleDef.ts new file mode 100644 index 000000000..2dadbf162 --- /dev/null +++ b/frontend/src/data/moduleDef.ts @@ -0,0 +1,14 @@ +import { createListResource } from "frappe-ui"; + +const moduleDef = createListResource({ + method: "GET", + doctype: "Module Def", + fields: ["name", "module_name"], + orderBy: "`module_name`", + cache: "moduleDef", + start: 0, + pageLength: 200, + auto: true, +}); + +export default moduleDef; diff --git a/frontend/src/types/Builder/BuilderPage.ts b/frontend/src/types/Builder/BuilderPage.ts index b6a43991e..869c96762 100644 --- a/frontend/src/types/Builder/BuilderPage.ts +++ b/frontend/src/types/Builder/BuilderPage.ts @@ -17,14 +17,14 @@ export interface BuilderPage{ page_name?: string /** Route : Data */ route?: string - /** Dynamic Route : Check - Map route parameters into form variables. Example /profile/<user> */ + /** Dynamic Route : Check - Map route parameters into form variables. Example /profile/:user */ dynamic_route?: 0 | 1 /** Is Standard : Check */ is_standard?: 0 | 1 /** Is Template : Check */ is_template?: 0 | 1 - /** Display Name : Data */ - display_name?: string + /** Module : Link - Module Def */ + module?: string /** Blocks : JSON */ blocks?: any /** Draft Blocks : JSON */ @@ -52,7 +52,7 @@ You can generate using favicon-generator.org */ meta_image?: string /** Canonical URL : Data - The preferred URL version of this page for search engines. If not set, the current page URL will be used. */ canonical_url?: string - /** Language : Data - Language code for HTML lang attribute (e.g., en, es, fr, de). If not set, the default language from Builder Settings will be used. */ + /** Language : Data - Language code for HTML (e.g., en, es, fr, de). Uses default if unset. */ language?: string /** Authenticated Access : Check - Only allow logged-in users to view this page. */ authenticated_access?: 0 | 1 From 138971409c560208551bd7f8b56cb38a5525ca61 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 8 Sep 2025 21:10:57 +0530 Subject: [PATCH 006/547] fix: Simplify export_page_as_standard and remove permission check --- .../doctype/builder_page/builder_page.py | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index f0ff7003f..8fe0a2b2c 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -872,14 +872,9 @@ def reset_block(block): return block -@frappe.whitelist() -def export_page_as_standard(page_name, target_app="builder", export_name=None): +def export_page_as_standard(page_name, target_app, export_name=None): """Export a builder page as standard files to the specified app""" - if not frappe.has_permission("Builder Page", ptype="write"): - frappe.throw("You do not have permission to export pages.") - page_doc = frappe.get_doc("Builder Page", page_name) - if not export_name: export_name = page_doc.page_name or page_name @@ -894,27 +889,15 @@ def export_page_as_standard(page_name, target_app="builder", export_name=None): # Create directories and get paths paths = create_export_directories(app_path, export_name) - # Export all fields except child tables - page_config = {} - for field in page_doc.meta.fields: - value = page_doc.get(field.fieldname) - if field.fieldtype in ("Datetime", "Date") and value is not None: - value = str(value) - page_config[field.fieldname] = value - - # Add some standard metadata fields - for key in ["creation", "modified", "owner", "modified_by"]: - value = getattr(page_doc, key, None) - if key in ["creation", "modified"] and value is not None: - value = str(value) - page_config[key] = value + page_config = page_doc.as_dict() config_file_path = os.path.join(paths["page_path"], "config.json") - blocks = frappe.parse_json(page_config["blocks"]) + blocks = frappe.parse_json(page_config["draft_blocks"] or page_config["blocks"]) if blocks: copy_assets_from_blocks(blocks, paths["assets_path"]) page_config["blocks"] = blocks + page_config["draft_blocks"] = None if page_doc.favicon: page_config["favicon"] = copy_asset_file(page_doc.favicon, paths["assets_path"]) @@ -934,12 +917,6 @@ def export_page_as_standard(page_name, target_app="builder", export_name=None): components = extract_components_from_blocks(blocks) export_components(components, paths["components_path"]) - return { - "success": True, - "message": f"Page exported successfully to {target_app}", - "export_path": paths["page_path"], - } - @frappe.whitelist() def duplicate_standard_page(app_name, page_folder_name, new_page_name=None): From 670ccd943e0dea7f1fd364802a62a8d9e6a1a9fb Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 9 Sep 2025 16:30:50 +0530 Subject: [PATCH 007/547] fix: Add script to sync on migrate --- .../doctype/builder_page/builder_page.py | 9 +++-- builder/install.py | 3 ++ builder/utils.py | 37 +++++++++++++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 8fe0a2b2c..9a872f8b4 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -9,7 +9,7 @@ import frappe import frappe.utils from frappe.modules import scrub -from frappe.modules.export_file import export_to_files +from frappe.modules.export_file import export_to_files, strip_default_fields from frappe.utils import set_request from frappe.utils.caching import redis_cache from frappe.utils.jinja import render_template @@ -889,11 +889,12 @@ def export_page_as_standard(page_name, target_app, export_name=None): # Create directories and get paths paths = create_export_directories(app_path, export_name) - page_config = page_doc.as_dict() + page_config = page_doc.as_dict(no_nulls=True) + page_config = strip_default_fields(page_doc, page_config) - config_file_path = os.path.join(paths["page_path"], "config.json") + config_file_path = os.path.join(paths["page_path"], f"{export_name}.json") - blocks = frappe.parse_json(page_config["draft_blocks"] or page_config["blocks"]) + blocks = frappe.parse_json(page_config.get("draft_blocks") or page_config["blocks"]) if blocks: copy_assets_from_blocks(blocks, paths["assets_path"]) page_config["blocks"] = blocks diff --git a/builder/install.py b/builder/install.py index 7df75eb21..79121563f 100644 --- a/builder/install.py +++ b/builder/install.py @@ -6,6 +6,7 @@ sync_block_templates, sync_builder_variables, sync_page_templates, + sync_standard_builder_pages, ) @@ -16,9 +17,11 @@ def after_install(): sync_block_templates() sync_builder_variables() add_composite_index_to_web_page_view() + sync_standard_builder_pages() def after_migrate(): sync_page_templates() sync_block_templates() sync_builder_variables() + sync_standard_builder_pages() diff --git a/builder/utils.py b/builder/utils.py index eff65ebbc..b6b9dc631 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -10,6 +10,7 @@ from urllib.parse import unquote, urlparse import frappe +from frappe.modules.export_file import export_to_files from frappe.modules.import_file import import_file_by_path from frappe.utils import get_site_base_path, get_site_path, get_url from frappe.utils.safe_exec import ( @@ -496,17 +497,20 @@ def extract_components_from_blocks(blocks): def export_client_scripts(page_doc, client_scripts_path): """Export client scripts for a page""" + from frappe.modules.export_file import strip_default_fields + for script_row in page_doc.client_scripts: script_doc = frappe.get_doc("Builder Client Script", script_row.builder_script) - script_config = { - "script_name": script_doc.name, - "script_type": script_doc.script_type, - "script": script_doc.script, - } - script_file_path = os.path.join(client_scripts_path, f"{frappe.scrub(str(script_doc.name))}.json") + script_config = script_doc.as_dict(no_nulls=True) + script_config = strip_default_fields(script_doc, script_config) + fname = frappe.scrub(str(script_doc.name)) + # ensure the target directory exists before writing the file + script_dir = os.path.join(client_scripts_path, fname) + os.makedirs(script_dir, exist_ok=True) + script_file_path = os.path.join(script_dir, f"{fname}.json") - with open(script_file_path, "w") as f: - json.dump(script_config, f, indent=2) + with open(script_file_path, "w", encoding="utf-8") as f: + f.write(frappe.as_json(script_config, ensure_ascii=False)) def export_components(components, components_path): @@ -565,3 +569,20 @@ def remove_existing_path(path): shutil.rmtree(path) else: os.remove(path) + + +def sync_standard_builder_pages(): + print("Syncing Standard Builder Pages") + # fetch pages from all apps under builder_files/pages + # import components first + + for app in frappe.get_installed_apps(): + app_path = frappe.get_app_path(app) + pages_path = os.path.join(app_path, "builder_files", "pages") + client_scripts_path = os.path.join(app_path, "builder_files", "client_scripts") + if os.path.exists(client_scripts_path): + print(f"Importing components from {client_scripts_path}") + make_records(client_scripts_path) + if os.path.exists(pages_path): + print(f"Importing page from {pages_path}") + make_records(pages_path) From 73c0ef30229841b9544355354fe69cbbc2578411 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 25 Sep 2025 16:47:32 +0530 Subject: [PATCH 008/547] fix: Remove explicit import --- .../Modals/ImportStandardPageModal.vue | 165 ------------------ frontend/src/pages/PageBuilderDashboard.vue | 7 +- 2 files changed, 1 insertion(+), 171 deletions(-) delete mode 100644 frontend/src/components/Modals/ImportStandardPageModal.vue diff --git a/frontend/src/components/Modals/ImportStandardPageModal.vue b/frontend/src/components/Modals/ImportStandardPageModal.vue deleted file mode 100644 index a4bfd2622..000000000 --- a/frontend/src/components/Modals/ImportStandardPageModal.vue +++ /dev/null @@ -1,165 +0,0 @@ - - - diff --git a/frontend/src/pages/PageBuilderDashboard.vue b/frontend/src/pages/PageBuilderDashboard.vue index f5da2d9c8..661cdd8dc 100644 --- a/frontend/src/pages/PageBuilderDashboard.vue +++ b/frontend/src/pages/PageBuilderDashboard.vue @@ -7,9 +7,6 @@ class="toolbar sticky top-0 z-10 flex h-12 items-center justify-end border-b-[1px] border-outline-gray-1 bg-surface-white p-2 px-3 py-1" ref="toolbar">
- - Import Standard - -

+

You don't have any pages yet. Click on the "+ New" button to create a new page.

@@ -159,13 +156,11 @@ v-model="showFolderSelectorDialog" :currentFolder="builderStore.activeFolder" @folderSelected="setFolder"> -
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fa1035301..90a6219f3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -35,4 +35,3 @@ declare global { is_developer_mode?: boolean; } } -window.is_developer_mode = process.env.NODE_ENV === "development"; From 754696ed498635392380c0ea9c612b44b2d2dcae Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Thu, 16 Oct 2025 11:58:17 +0530 Subject: [PATCH 012/547] fix: don't convert to string --- frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index f356630d9..158f825df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@ window.builder_path = "{{ builder_path }}"; window.site_name = "{{ site_name }}"; window.is_fc_site = "{{ is_fc_site }}"; - window.is_developer_mode = "{{ is_developer_mode }}"; + window.is_developer_mode = {{ is_developer_mode }}; From 4eb6946eddf172037aacd07c51cb86f7a4076606 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Mon, 20 Oct 2025 19:25:25 +0530 Subject: [PATCH 013/547] feat: add block props and block js scripts --- .../doctype/builder_page/builder_page.py | 44 +++ frontend/src/block.ts | 28 ++ frontend/src/builder.d.ts | 10 + frontend/src/components/BlockProperties.vue | 4 + .../BlockPropertySections/PropsSection.ts | 40 +++ .../BlockPropertySections/ScriptEditor.ts | 33 +++ frontend/src/components/PropsEditor.vue | 254 ++++++++++++++++++ frontend/src/utils/blockController.ts | 43 +++ 8 files changed, 456 insertions(+) create mode 100644 frontend/src/components/BlockPropertySections/PropsSection.ts create mode 100644 frontend/src/components/BlockPropertySections/ScriptEditor.ts create mode 100644 frontend/src/components/PropsEditor.vue diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 2d3ccb3af..475c3369e 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -488,6 +488,7 @@ def get_block_html(blocks): font_map = {} def get_html(blocks, soup): + map_of_inherited_props = {} # block_id -> ancestor_block_id -> prop_key -> prop_value html = "" def get_tag(block, soup, data_key=None): @@ -565,6 +566,33 @@ def get_tag(block, soup, data_key=None): inner_soup = bs.BeautifulSoup(innerContent, "html.parser") set_fonts_from_html(inner_soup, font_map) tag.append(inner_soup) + script_tag = soup.new_tag("script") + props_obj = False + if block.get("props"): + props = [] + for key, value in block.get("props", {}).items(): + prop_value = "" + if value["type"] == "dynamic": + if data_key: + prop_value = f"{{{{ {data_key}.{value['value']} }}}}" + else: + prop_value = f"{{{{ {value['value']} }}}}" + elif value["type"] == "static": + prop_value = f"{value['value']}" + elif value["type"] == "inherited": + ancestor_id = value.get("ancestorBlockId") + if ancestor_id and block.get("blockId") in map_of_inherited_props: + prop_value = map_of_inherited_props[block.get('blockId')].get(ancestor_id, '').get(value['value'], '') + props.append(f"{key}: {prop_value}") + if value.get('usedBy'): + for child_block_id in value.get('usedBy'): + if child_block_id in map_of_inherited_props: + map_of_ancestor_props_for_block_id = map_of_inherited_props.get(child_block_id, {}) + map_of_ancestor_props_for_block_id[block.get("blockId")] = {key: prop_value} + else: + map_of_inherited_props[child_block_id] = {block.get("blockId"): {key: prop_value}} + + props_obj = f"{{ {', '.join(props)} }}" if block.get("isRepeaterBlock") and block.get("children") and block.get("dataKey"): _key = block.get("dataKey").get("key") @@ -589,6 +617,14 @@ def get_tag(block, soup, data_key=None): if element == "body": tag.append("{% include 'templates/generators/webpage_scripts.html' %}") + if block.get("blockScript"): + block_unique_id = f"{block.get('blockId')}-{frappe.generate_hash(length=3)}" + script_content = f"(function (props){{ {block.get('blockScript')} }}).call(document.querySelector('[data-block-id=\"{block_unique_id}\"]'), {props_obj or '{}'});" + print("Script content: ", script_content) + script_tag.string = script_content + tag.attrs["data-block-id"] = block_unique_id + tag.append(script_tag) + return tag for block in blocks: @@ -699,6 +735,14 @@ def extend_block(block, overridden_block): block["rawStyles"].update(overridden_block.get("rawStyles", {})) block["classes"].extend(overridden_block["classes"]) + + if not block.get("props"): + block["props"] = {} + block["props"].update(overridden_block.get("props", {})) + + if overridden_block.get("blockScript"): + block["blockScript"] = overridden_block.get("blockScript") + dataKey = overridden_block.get("dataKey", {}) if not block.get("dataKey"): block["dataKey"] = {} diff --git a/frontend/src/block.ts b/frontend/src/block.ts index bbbf0e247..fd774e3c7 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -42,6 +42,8 @@ class Block implements BlockOptions { parentBlock: Block | null; activeState?: string | null = null; dynamicValues: Array; + blockScript?: string; + props?: BlockProps; // @ts-expect-error referenceComponent: Block | null; customAttributes: BlockAttributeMap; @@ -97,6 +99,8 @@ class Block implements BlockOptions { this.tabletStyles = reactive(options.tabletStyles || {}); this.attributes = reactive(options.attributes || {}); this.dynamicValues = reactive(options.dynamicValues || []); + this.blockScript = options.blockScript || ""; + this.props = options.props || {}; this.blockName = options.blockName; delete this.attributes.style; @@ -889,6 +893,30 @@ class Block implements BlockOptions { isInsideRepeater(): boolean { return Boolean(this.getRepeaterParent()); } + getBlockScript(): string { + let blockScript = ""; + if(this.isExtendedFromComponent() && !this.blockScript){ + blockScript = this.referenceComponent?.getBlockScript() || ""; + } else { + blockScript = this.blockScript || ""; + } + return blockScript; + } + setBlockScript(script: string) { + this.blockScript = script; + } + getBlockProps(): BlockProps { + let blockProps = {}; + if(this.isExtendedFromComponent() && !Object.keys(this.props || {}).length){ + blockProps = this.referenceComponent?.getBlockProps() || {}; + } else { + blockProps = this.props || {}; + } + return blockProps; + } + setBlockProps(props: BlockProps){ + this.props = props; + } } function extendWithComponent( diff --git a/frontend/src/builder.d.ts b/frontend/src/builder.d.ts index aa7550eda..8dcb43f51 100644 --- a/frontend/src/builder.d.ts +++ b/frontend/src/builder.d.ts @@ -6,6 +6,16 @@ declare interface BlockStyleMap { [key: styleProperty]: StyleValue; } +declare type BlockProps = Record< + string, + { + type: "dynamic" | "static" | "inherited"; + value: string?; + usedBy?: Array; + ancestorBlockId?: string; + } +>; + declare interface BlockAttributeMap { [key: string]: string | number | null | undefined; } diff --git a/frontend/src/components/BlockProperties.vue b/frontend/src/components/BlockProperties.vue index 52ed0d380..3627c6db0 100644 --- a/frontend/src/components/BlockProperties.vue +++ b/frontend/src/components/BlockProperties.vue @@ -48,6 +48,8 @@ import styleSection from "@/components/BlockPropertySections/StyleSection"; import transitionSection from "@/components/BlockPropertySections/TransitionSection"; import typographySection from "@/components/BlockPropertySections/TypographySection"; import videoOptionsSection from "@/components/BlockPropertySections/VideoOptionsSection"; +import blockScriptSection from "@/components/BlockPropertySections/ScriptEditor"; +import blockPropsSection from "@/components/BlockPropertySections/PropsSection"; import useBuilderStore from "@/stores/builderStore"; import blockController from "@/utils/blockController"; import { toValue } from "@vueuse/core"; @@ -119,5 +121,7 @@ const sections = [ dataKeySection, customAttributesSection, rawStyleSection, + blockScriptSection, + blockPropsSection, ] as PropertySection[]; diff --git a/frontend/src/components/BlockPropertySections/PropsSection.ts b/frontend/src/components/BlockPropertySections/PropsSection.ts new file mode 100644 index 000000000..0c8316d70 --- /dev/null +++ b/frontend/src/components/BlockPropertySections/PropsSection.ts @@ -0,0 +1,40 @@ +import PropsEditor from "../PropsEditor.vue"; +import blockController from "@/utils/blockController"; +import { computed } from "vue"; + +const rawStyleSectionProperties = [ + { + component: PropsEditor, + getProps: () => { + return { + obj: blockController.getBlockProps(), + description: ` + Note: +
+
+ • A block can have multiple props +
+ • Props can be static values, dynamic values (from data script), or inherited from ancestor blocks + `, + }; + }, + searchKeyWords: "Props, Interface Props, Properties, Block Props, Block Properties", + events: { + "update:obj": (obj: BlockProps) => blockController.setBlockProps(obj), // TODO: race condition? + "update:ancestorUpdateDependency": ( + propKey: string, + ancestorBlockId: string, + action: "add" | "remove", // TODO: better name? + ) => blockController.updateBlockPropsDependencyForAncestor(propKey, ancestorBlockId, action), + }, + }, +]; + +export default { + name: "Props", + properties: rawStyleSectionProperties, + collapsed: computed(() => { + return Object.keys(blockController.getBlockProps()).length === 0; + }), + condition: () => !blockController.multipleBlocksSelected(), +}; diff --git a/frontend/src/components/BlockPropertySections/ScriptEditor.ts b/frontend/src/components/BlockPropertySections/ScriptEditor.ts new file mode 100644 index 000000000..36e790477 --- /dev/null +++ b/frontend/src/components/BlockPropertySections/ScriptEditor.ts @@ -0,0 +1,33 @@ +import { computed } from "vue"; +import CodeEditor from "../Controls/CodeEditor.vue"; +import blockController from "@/utils/blockController"; + +const blockScriptProperties = [ + { + component: CodeEditor, + getProps: () => { + return { + modelValue: blockController.getBlockScript(), + type: "JavaScript", + readonly: false, + height: "200px", + showLineNumbers: true, + autofocus: true, + showSaveButton: true, + description: + "Add custom scripts to enhance block functionality.", + }; + }, + searchKeyWords: "Block Script, Script, JS, JavaScript, Custom Script", + events: { + save: (script: string) => blockController.setBlockScript(script), + }, + }, +]; + +export default { + name: "Block Script", + properties: blockScriptProperties, + collapsed: !blockController.getBlockScript()?.trim(), + condition: () => !blockController.multipleBlocksSelected(), +}; diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue new file mode 100644 index 000000000..87311ea24 --- /dev/null +++ b/frontend/src/components/PropsEditor.vue @@ -0,0 +1,254 @@ + + diff --git a/frontend/src/utils/blockController.ts b/frontend/src/utils/blockController.ts index 0ee121ad9..d0e7b4aec 100644 --- a/frontend/src/utils/blockController.ts +++ b/frontend/src/utils/blockController.ts @@ -301,6 +301,49 @@ const blockController = { block.unsetLink(); }); }, + getBlockScript: () => { + return blockController.getSelectedBlocks()[0]?.getBlockScript(); // TODO: change to first selected block + }, + setBlockScript: (script: string) => { + blockController.getSelectedBlocks()[0]?.setBlockScript(script); + }, + getBlockProps: () => { + return blockController.getSelectedBlocks()[0]?.getBlockProps(); + }, + setBlockProps: (props: BlockProps) => { + blockController.getSelectedBlocks()[0]?.setBlockProps(props); + }, + // TODO: should go in other file? + updateBlockPropsDependencyForAncestor: ( + propKey: string, + ancestorBlockId: string, + action: "add" | "remove", + ) => { + let currentBlock = blockController.getFirstSelectedBlock(); + // go up the tree + let parentBlock: Block | null = currentBlock.getParentBlock(); + while (parentBlock?.blockId != ancestorBlockId) { + parentBlock = parentBlock?.getParentBlock() || null; + } + if (parentBlock) { + let props = parentBlock.props; + if (props) { + let usedBy = props[propKey]["usedBy"]; + if (action === "add") { + usedBy = [...new Set([...(usedBy || []), currentBlock.blockId])]; + } else { + usedBy = usedBy?.filter((blockId) => blockId != currentBlock.blockId); + } + parentBlock.setBlockProps({ + ...props, + [propKey]: { + ...props[propKey], + usedBy, + }, + }); + } + } + }, }; export default blockController; From e484e777fd5de743efbb88a8e42c6c71a98bab65 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Thu, 23 Oct 2025 15:07:17 +0530 Subject: [PATCH 014/547] fix(block-props): disallow empty prop names --- frontend/src/components/PropsEditor.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue index 87311ea24..b30ab2a4e 100644 --- a/frontend/src/components/PropsEditor.vue +++ b/frontend/src/components/PropsEditor.vue @@ -61,6 +61,8 @@ import { computed, defineComponent, h, nextTick, ref, shallowRef, useAttrs, watc import Autocomplete from "@/components/Controls/Autocomplete.vue"; import usePageStore from "@/stores/pageStore"; import blockController from "@/utils/blockController"; +import { toast } from "vue-sonner"; + import Block from "@/block"; // @ts-ignore import LucideZap from "~icons/lucide/zap"; @@ -193,6 +195,10 @@ const clearObjectValue = (key: string) => { }; const updateObjectValue = (key: string, value: string | null) => { + if(!key){ + toast.error("Property name cannot be empty."); + return; + } if (!value) { clearObjectValue(key); return; From 5e5bec8dae3a2279ff88f778f67e8dee88eaf778 Mon Sep 17 00:00:00 2001 From: Sayantan Ghosh Date: Fri, 24 Oct 2025 10:30:21 +0530 Subject: [PATCH 015/547] fix(block-props): changed const name in PropsSection.ts used the rawStylesSection as a template to create the props section to maintain consistency but forgot to update the name --- frontend/src/components/BlockPropertySections/PropsSection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/BlockPropertySections/PropsSection.ts b/frontend/src/components/BlockPropertySections/PropsSection.ts index 0c8316d70..78673eb6e 100644 --- a/frontend/src/components/BlockPropertySections/PropsSection.ts +++ b/frontend/src/components/BlockPropertySections/PropsSection.ts @@ -2,7 +2,7 @@ import PropsEditor from "../PropsEditor.vue"; import blockController from "@/utils/blockController"; import { computed } from "vue"; -const rawStyleSectionProperties = [ +const propsSection = [ { component: PropsEditor, getProps: () => { @@ -32,7 +32,7 @@ const rawStyleSectionProperties = [ export default { name: "Props", - properties: rawStyleSectionProperties, + properties: propsSection, collapsed: computed(() => { return Object.keys(blockController.getBlockProps()).length === 0; }), From 162eff82b8fe448e28d32ec73a2fe0e1f3b6d274 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 27 Oct 2025 11:39:03 +0530 Subject: [PATCH 016/547] test: Add tests to improve utils coverage --- builder/builder/tests/test_utils.py | 316 ++++++++++++++++++++++++++++ builder/utils.py | 11 +- 2 files changed, 321 insertions(+), 6 deletions(-) diff --git a/builder/builder/tests/test_utils.py b/builder/builder/tests/test_utils.py index b5412eb1b..7f0dda194 100644 --- a/builder/builder/tests/test_utils.py +++ b/builder/builder/tests/test_utils.py @@ -1,18 +1,27 @@ import os +from unittest.mock import patch import frappe from frappe.tests.utils import FrappeTestCase from builder.utils import ( + Block, ColonRule, camel_case_to_kebab_case, clean_data, + copy_asset_file, + copy_assets_from_blocks, + copy_img_to_asset_folder, escape_single_quotes, execute_script, + extract_components_from_blocks, get_builder_page_preview_file_paths, get_template_assets_folder_path, is_component_used, + make_safe_get_request, + process_block_assets, remove_unsafe_fields, + split_styles, ) @@ -145,3 +154,310 @@ def test_clean_data(self): } cleaned_data = clean_data(data) self.assertEqual(cleaned_data, {"test": "value", "test2": "value2", "test4": None, "test5": {}}) + + def test_make_safe_get_request(self): + # Test with local/private IP addresses (should return None) + self.assertIsNone(make_safe_get_request("http://127.0.0.1/test")) + self.assertIsNone(make_safe_get_request("http://localhost/test")) + + # Test with invalid URL + with self.assertRaises(Exception): + make_safe_get_request("not-a-url") + + def test_split_styles(self): + # Test with None + result = split_styles(None) + self.assertEqual(result, {"regular": {}, "state": {}}) + + # Test with mixed styles + styles = {"color": "red", "margin": "10px", "hover:color": "blue", "focus:border": "1px solid black"} + result = split_styles(styles) + + self.assertEqual(result["regular"], {"color": "red", "margin": "10px"}) + self.assertEqual(result["state"], {"hover:color": "blue", "focus:border": "1px solid black"}) + + def test_copy_assets_from_blocks(self): + # Create a temporary directory for testing + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with single block + block = Block(element="img", attributes={"src": "/files/test.jpg"}) + copy_assets_from_blocks(block, temp_dir) + + # Test with list of blocks + blocks = [ + { + "element": "div", + "children": [{"element": "img", "attributes": {"src": "/files/test2.jpg"}}], + }, + {"element": "video", "attributes": {"src": "/files/test.mp4"}}, + ] + copy_assets_from_blocks(blocks, temp_dir) + + def test_process_block_assets(self): + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with img element + block = {"element": "img", "attributes": {"src": "/files/test.jpg"}} + process_block_assets(block, temp_dir) + + # Test with video element + block = {"element": "video", "attributes": {"src": "/files/test.mp4"}} + process_block_assets(block, temp_dir) + + # Test with non-media element + block = {"element": "div", "attributes": {"class": "test"}} + process_block_assets(block, temp_dir) + + def test_copy_asset_file(self): + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Test with None/invalid inputs + result = copy_asset_file(None, temp_dir) + self.assertIsNone(result) + + result = copy_asset_file("", temp_dir) + self.assertIsNone(result) + + result = copy_asset_file(123, temp_dir) + self.assertIsNone(result) + + # Test with non-existent file URLs + result = copy_asset_file("/files/nonexistent.jpg", temp_dir) + self.assertIsNone(result) + + result = copy_asset_file("/builder_assets/nonexistent.jpg", temp_dir) + self.assertIsNone(result) + + def test_extract_components_from_blocks(self): + # Test with blocks containing components + blocks = [ + { + "element": "div", + "extendedFromComponent": "TestComponent1", + "children": [{"element": "span", "extendedFromComponent": "TestComponent2"}], + }, + { + "element": "section", + "children": [{"element": "div", "extendedFromComponent": "TestComponent1"}], + }, + ] + + # Mock frappe.get_cached_doc using unittest.mock + + with patch("frappe.get_cached_doc") as mock_get_cached_doc: + mock_get_cached_doc.return_value = frappe._dict(block='{"element": "div"}') + + components = extract_components_from_blocks(blocks) + self.assertIn("TestComponent1", components) + self.assertIn("TestComponent2", components) + + # Test with single block (not a list) + single_block = {"element": "div", "extendedFromComponent": "SingleComponent"} + + with patch("frappe.get_cached_doc") as mock_get_cached_doc: + mock_get_cached_doc.return_value = frappe._dict(block='{"element": "div"}') + + components = extract_components_from_blocks(single_block) + self.assertIn("SingleComponent", components) + + def test_copy_img_to_asset_folder(self): + test_page = frappe._dict(name="test-page") + + # Test with non-img elements (should be ignored) + block = Block() + block.element = "div" + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element but no attributes + block = Block() + block.element = "img" + block.attributes = None + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element but no src attribute + block = Block() + block.element = "img" + block.attributes = frappe._dict() + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not raise error + + # Test with img element and external src (should be ignored) + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="https://example.com/image.jpg") + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test with img element and builder_assets src (should be ignored) + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/builder_assets/local-image.jpg") + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test with nested blocks containing img elements + child_block = Block() + child_block.element = "img" + child_block.attributes = frappe._dict(src="https://example.com/nested.jpg") + child_block.children = [] + + parent_block = Block() + parent_block.element = "div" + parent_block.attributes = frappe._dict() + parent_block.children = [child_block] + + copy_img_to_asset_folder(parent_block, test_page) # Should process children recursively + + # Test with local file src that doesn't exist in database + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/nonexistent-image.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] # No files found + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + # Should update src even if file not found + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/nonexistent-image.jpg") + + # Test with valid local file that exists in database + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/test-image.jpg") + block.children = [] + + mock_file = frappe._dict() + mock_file.get_full_path = lambda: "/fake/path/test-image.jpg" + + with ( + patch("frappe.get_all") as mock_get_all, + patch("frappe.get_doc") as mock_get_doc, + patch("shutil.copy") as mock_copy, + patch("builder.utils.get_template_assets_folder_path") as mock_get_path, + ): + mock_get_all.return_value = [frappe._dict(name="file-123")] + mock_get_doc.return_value = mock_file + mock_get_path.return_value = "/fake/assets/path" + + copy_img_to_asset_folder(block, test_page) + + # Verify file operations were called correctly + mock_get_all.assert_called_once_with( + "File", filters={"file_url": "/files/test-image.jpg"}, fields=["name"] + ) + mock_get_doc.assert_called_once_with("File", "file-123") + mock_copy.assert_called_once_with("/fake/path/test-image.jpg", "/fake/assets/path") + + # Verify src was updated correctly + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/test-image.jpg") + + # Test with site URL prefix in src + site_url = frappe.utils.get_url() + block = Block() + block.element = "img" + block.attributes = frappe._dict(src=f"{site_url}/files/prefixed-image.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] + copy_img_to_asset_folder(block, test_page) + # Should strip site URL and update path + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/prefixed-image.jpg") + + # Test with URL-encoded filename + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/image%20with%20spaces.jpg") + block.children = [] + + with patch("frappe.get_all") as mock_get_all: + # Should decode URL and search for decoded filename + mock_get_all.return_value = [] + copy_img_to_asset_folder(block, test_page) + mock_get_all.assert_called_with( + "File", filters={"file_url": "/files/image with spaces.jpg"}, fields=["name"] + ) + self.assertEqual(block.attributes.src, f"/builder_assets/{test_page.name}/image with spaces.jpg") + + # Test error handling when file copy fails + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/files/error-test.jpg") + block.children = [] + + mock_file = frappe._dict() + mock_file.get_full_path = lambda: "/fake/path/error-test.jpg" + + with ( + patch("frappe.get_all") as mock_get_all, + patch("frappe.get_doc") as mock_get_doc, + patch("shutil.copy") as mock_copy, + patch("builder.utils.get_template_assets_folder_path") as mock_get_path, + ): + mock_get_all.return_value = [frappe._dict(name="file-456")] + mock_get_doc.return_value = mock_file + mock_get_path.return_value = "/fake/assets/path" + mock_copy.side_effect = OSError("Permission denied") + + # Function should raise exception when copy fails + with self.assertRaises(OSError): + copy_img_to_asset_folder(block, test_page) + + # Test with empty/None src attribute + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="") + block.children = [] + copy_img_to_asset_folder(block, test_page) # Should not process empty src + + block.attributes = frappe._dict(src=None) + copy_img_to_asset_folder(block, test_page) # Should not process None src + + # Test with malformed URLs + block = Block() + block.element = "img" + block.attributes = frappe._dict(src="/uploads/image.jpg") # Not a /files path + block.children = [] + original_src = block.attributes.src + copy_img_to_asset_folder(block, test_page) + self.assertEqual(block.attributes.src, original_src) # Should remain unchanged + + # Test deeply nested structure + grandchild = Block() + grandchild.element = "img" + grandchild.attributes = frappe._dict(src="/files/deep-nested.jpg") + grandchild.children = [] + + child = Block() + child.element = "span" + child.attributes = frappe._dict() + child.children = [grandchild] + + parent = Block() + parent.element = "div" + parent.attributes = frappe._dict() + parent.children = [child] + + with patch("frappe.get_all") as mock_get_all: + mock_get_all.return_value = [] + copy_img_to_asset_folder(parent, test_page) + # Should process grandchild img element + self.assertEqual(grandchild.attributes.src, f"/builder_assets/{test_page.name}/deep-nested.jpg") + + # Test with block that has None children + block = Block() + block.element = "div" + block.attributes = frappe._dict() + block.children = None + copy_img_to_asset_folder(block, test_page) # Should handle None children gracefully diff --git a/builder/utils.py b/builder/utils.py index 2b237d509..5e8ff0996 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -271,8 +271,8 @@ def make_records(path): def copy_img_to_asset_folder(block: Block, page_doc): - if block.get("element") == "img": - src = block.get("attributes", {}).get("src") + if block.element == "img": + src = getattr(block.attributes, "src", None) if block.attributes else None site_url = get_url() if src and (src.startswith(f"{site_url}/files") or src.startswith("/files")): @@ -281,16 +281,15 @@ def copy_img_to_asset_folder(block: Block, page_doc): src = src.split(f"{site_url}")[1] # url decode src = unquote(src) - print(f"src: {src}") files = frappe.get_all("File", filters={"file_url": src}, fields=["name"]) - print(f"files: {files}") if files: _file = frappe.get_doc("File", files[0].name) # copy physical file to new location assets_folder_path = get_template_assets_folder_path(page_doc) shutil.copy(_file.get_full_path(), assets_folder_path) - block.get("attributes", {})["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" - for child in block.get("children", []) or []: + if block.attributes: + block.attributes["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" + for child in block.children or []: copy_img_to_asset_folder(child, page_doc) From 869160f59a4074e294e38a35bf35482442e338e3 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 27 Oct 2025 12:03:21 +0530 Subject: [PATCH 017/547] fix(utils): Handle attribute error --- builder/utils.py | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/builder/utils.py b/builder/utils.py index 5e8ff0996..c58af345d 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -270,9 +270,33 @@ def make_records(path): # ) -def copy_img_to_asset_folder(block: Block, page_doc): - if block.element == "img": - src = getattr(block.attributes, "src", None) if block.attributes else None +def copy_img_to_asset_folder(block, page_doc): + # Helper function to safely get attribute from block (dict or object) + def safe_get(obj, attr, default=None): + if isinstance(obj, dict): + return obj.get(attr, default) + else: + return getattr(obj, attr, default) + + # Convert dict to frappe._dict for consistent access + if isinstance(block, dict): + block = frappe._dict(block) + # Also convert children to frappe._dict for consistent access + children = block.get("children", []) + if children and isinstance(children, list): + block.children = [frappe._dict(child) if isinstance(child, dict) else child for child in children] + + # Get element safely + element = safe_get(block, "element") + + if element == "img": + # Get attributes safely + attributes = safe_get(block, "attributes") + src = None + + if attributes: + src = safe_get(attributes, "src") + site_url = get_url() if src and (src.startswith(f"{site_url}/files") or src.startswith("/files")): @@ -287,9 +311,17 @@ def copy_img_to_asset_folder(block: Block, page_doc): # copy physical file to new location assets_folder_path = get_template_assets_folder_path(page_doc) shutil.copy(_file.get_full_path(), assets_folder_path) - if block.attributes: - block.attributes["src"] = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" - for child in block.children or []: + + new_src = f"/builder_assets/{page_doc.name}/{src.split('/')[-1]}" + if attributes: + if isinstance(attributes, dict): + attributes["src"] = new_src + else: + attributes.src = new_src + + # Process children safely + children = safe_get(block, "children", []) + for child in children or []: copy_img_to_asset_folder(child, page_doc) From de369a677cb0e0b9ae3539ac95f69e02c52ac0a8 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 27 Oct 2025 15:28:58 +0530 Subject: [PATCH 018/547] fix: Make sure standard pages are synced on app install --- builder/hooks.py | 1 + builder/install.py | 4 ++++ builder/utils.py | 6 ++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/builder/hooks.py b/builder/hooks.py index 7b4231d32..bc3289e57 100644 --- a/builder/hooks.py +++ b/builder/hooks.py @@ -66,6 +66,7 @@ # before_install = "builder.install.before_install" after_install = "builder.install.after_install" after_migrate = "builder.install.after_migrate" +after_app_install = "builder.install.after_app_install" # Uninstallation # ------------ diff --git a/builder/install.py b/builder/install.py index 79121563f..726f6a933 100644 --- a/builder/install.py +++ b/builder/install.py @@ -25,3 +25,7 @@ def after_migrate(): sync_block_templates() sync_builder_variables() sync_standard_builder_pages() + + +def after_app_install(app_name=None): + sync_standard_builder_pages(app_name) diff --git a/builder/utils.py b/builder/utils.py index c58af345d..a4fd5d4ed 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -604,12 +604,14 @@ def remove_existing_path(path): os.remove(path) -def sync_standard_builder_pages(): +def sync_standard_builder_pages(app_name=None): print("Syncing Standard Builder Pages") # fetch pages from all apps under builder_files/pages # import components first - for app in frappe.get_installed_apps(): + apps_to_sync = [app_name] if app_name else frappe.get_installed_apps() + + for app in apps_to_sync: app_path = frappe.get_app_path(app) pages_path = os.path.join(app_path, "builder_files", "pages") components_path = os.path.join(app_path, "builder_files", "components") From 469c622c6cb3182fe7ad3da7d2d58355b3e27ddb Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 27 Oct 2025 15:29:44 +0530 Subject: [PATCH 019/547] fix: Use _ if component name has "/" to produce valid folder name --- builder/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/builder/utils.py b/builder/utils.py index a4fd5d4ed..77485ec0d 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -549,11 +549,11 @@ def export_components(components, components_path): for component_id in components: try: component_doc = frappe.get_doc("Builder Component", component_id) - component_dir = os.path.join(components_path, frappe.scrub(component_doc.component_name)) + # Replace forward slashes with underscores to create valid directory names + safe_component_name = frappe.scrub(component_doc.component_name).replace("/", "_") + component_dir = os.path.join(components_path, safe_component_name) os.makedirs(component_dir, exist_ok=True) - component_file_path = os.path.join( - component_dir, f"{frappe.scrub(component_doc.component_name)}.json" - ) + component_file_path = os.path.join(component_dir, f"{safe_component_name}.json") with open(component_file_path, "w") as f: f.write(frappe.as_json(component_doc.as_dict())) From 97e3e08827ac0db6860834843fb0e7eb1b05d760 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Thu, 23 Oct 2025 15:07:17 +0530 Subject: [PATCH 020/547] fix(block-props): disallow empty prop names --- frontend/src/components/PropsEditor.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue index 87311ea24..b30ab2a4e 100644 --- a/frontend/src/components/PropsEditor.vue +++ b/frontend/src/components/PropsEditor.vue @@ -61,6 +61,8 @@ import { computed, defineComponent, h, nextTick, ref, shallowRef, useAttrs, watc import Autocomplete from "@/components/Controls/Autocomplete.vue"; import usePageStore from "@/stores/pageStore"; import blockController from "@/utils/blockController"; +import { toast } from "vue-sonner"; + import Block from "@/block"; // @ts-ignore import LucideZap from "~icons/lucide/zap"; @@ -193,6 +195,10 @@ const clearObjectValue = (key: string) => { }; const updateObjectValue = (key: string, value: string | null) => { + if(!key){ + toast.error("Property name cannot be empty."); + return; + } if (!value) { clearObjectValue(key); return; From 7cecd619fcecdbf8b00bf78f1d085245542cc22d Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Thu, 30 Oct 2025 00:19:08 +0530 Subject: [PATCH 021/547] fix(block-props): removed blockId dependency for props previously, block props relied on blockIds to manage inheritance. This caused issues when using components - each component instance generated new blockIds for its blocks, breaking prop references that still pointed to the original component blockIds. maintaining and updating these blockId dependencies became increasingly complex and error-prone. this commit removes the dependency on blockIds entirely. Props are now identified by their names, and a stack-based approach is used to resolve inherited values. the system now behaves similarly to variable scoping in programming languages - where inherited props take the value of the nearest ancestor with the same prop name in the faimly tree (block scoping in programming) --- .../doctype/builder_page/builder_page.py | 50 ++++++------ frontend/components.d.ts | 1 + frontend/src/block.ts | 12 +-- .../BlockPropertySections/PropsSection.ts | 3 +- frontend/src/components/PropsEditor.vue | 79 +++++++++---------- frontend/src/utils/blockController.ts | 22 ++++-- 6 files changed, 89 insertions(+), 78 deletions(-) diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 475c3369e..9d59cacb7 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -488,7 +488,7 @@ def get_block_html(blocks): font_map = {} def get_html(blocks, soup): - map_of_inherited_props = {} # block_id -> ancestor_block_id -> prop_key -> prop_value + map_of_inherited_props = {} # prop_name -> array html = "" def get_tag(block, soup, data_key=None): @@ -566,31 +566,34 @@ def get_tag(block, soup, data_key=None): inner_soup = bs.BeautifulSoup(innerContent, "html.parser") set_fonts_from_html(inner_soup, font_map) tag.append(inner_soup) + script_tag = soup.new_tag("script") - props_obj = False + props_obj = {} + props_with_successors = [] if block.get("props"): props = [] for key, value in block.get("props", {}).items(): - prop_value = "" - if value["type"] == "dynamic": - if data_key: - prop_value = f"{{{{ {data_key}.{value['value']} }}}}" - else: - prop_value = f"{{{{ {value['value']} }}}}" - elif value["type"] == "static": - prop_value = f"{value['value']}" - elif value["type"] == "inherited": - ancestor_id = value.get("ancestorBlockId") - if ancestor_id and block.get("blockId") in map_of_inherited_props: - prop_value = map_of_inherited_props[block.get('blockId')].get(ancestor_id, '').get(value['value'], '') - props.append(f"{key}: {prop_value}") - if value.get('usedBy'): - for child_block_id in value.get('usedBy'): - if child_block_id in map_of_inherited_props: - map_of_ancestor_props_for_block_id = map_of_inherited_props.get(child_block_id, {}) - map_of_ancestor_props_for_block_id[block.get("blockId")] = {key: prop_value} - else: - map_of_inherited_props[child_block_id] = {block.get("blockId"): {key: prop_value}} + + prop_value = value["value"] + prop_type = value["type"] + interpreted_value = "" + + if prop_value == "" or prop_value is None: + prop_value = "undefined" + + if prop_type == "dynamic": + interpreted_value = f"{{{{ {data_key}.{prop_value} }}}}" if data_key else f"{{{{ {prop_value} }}}}" + elif prop_type == "static": + interpreted_value = f"{prop_value}" + elif prop_type == "inherited": + values = map_of_inherited_props.get(prop_value, []) + interpreted_value = values[0] if values else "undefined" + + props.append(f"{key}: {interpreted_value}") + + if value.get('usedByCount', 0) > 0: + props_with_successors.append(key) + map_of_inherited_props.setdefault(key, []).append(interpreted_value) props_obj = f"{{ {', '.join(props)} }}" @@ -617,6 +620,9 @@ def get_tag(block, soup, data_key=None): if element == "body": tag.append("{% include 'templates/generators/webpage_scripts.html' %}") + for props in props_with_successors: + map_of_inherited_props[props].pop() + if block.get("blockScript"): block_unique_id = f"{block.get('blockId')}-{frappe.generate_hash(length=3)}" script_content = f"(function (props){{ {block.get('blockScript')} }}).call(document.querySelector('[data-block-id=\"{block_unique_id}\"]'), {props_obj or '{}'});" diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 658b0fa9c..6d1ee5078 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -104,6 +104,7 @@ declare module 'vue' { Play: typeof import('./src/components/Icons/Play.vue')['default'] Plus: typeof import('./src/components/Icons/Plus.vue')['default'] PropertyControl: typeof import('./src/components/Controls/PropertyControl.vue')['default'] + PropsEditor: typeof import('./src/components/PropsEditor.vue')['default'] PublishButton: typeof import('./src/components/PublishButton.vue')['default'] RangeInput: typeof import('./src/components/Controls/RangeInput.vue')['default'] Redirect: typeof import('./src/components/Icons/Redirect.vue')['default'] diff --git a/frontend/src/block.ts b/frontend/src/block.ts index fd774e3c7..85ffa88a3 100644 --- a/frontend/src/block.ts +++ b/frontend/src/block.ts @@ -100,7 +100,7 @@ class Block implements BlockOptions { this.attributes = reactive(options.attributes || {}); this.dynamicValues = reactive(options.dynamicValues || []); this.blockScript = options.blockScript || ""; - this.props = options.props || {}; + this.props = reactive(options.props || {}); this.blockName = options.blockName; delete this.attributes.style; @@ -894,8 +894,8 @@ class Block implements BlockOptions { return Boolean(this.getRepeaterParent()); } getBlockScript(): string { - let blockScript = ""; - if(this.isExtendedFromComponent() && !this.blockScript){ + let blockScript = ""; + if (this.isExtendedFromComponent() && !this.blockScript) { blockScript = this.referenceComponent?.getBlockScript() || ""; } else { blockScript = this.blockScript || ""; @@ -907,14 +907,14 @@ class Block implements BlockOptions { } getBlockProps(): BlockProps { let blockProps = {}; - if(this.isExtendedFromComponent() && !Object.keys(this.props || {}).length){ + if (this.isExtendedFromComponent() && !Object.keys(this.props || {}).length) { blockProps = this.referenceComponent?.getBlockProps() || {}; } else { blockProps = this.props || {}; } return blockProps; } - setBlockProps(props: BlockProps){ + setBlockProps(props: BlockProps) { this.props = props; } } @@ -1021,6 +1021,8 @@ function resetBlock( block.customAttributes = {}; block.classes = []; block.dataKey = null; + block.props = {}; + block.blockScript = ""; } if (resetChildren) { diff --git a/frontend/src/components/BlockPropertySections/PropsSection.ts b/frontend/src/components/BlockPropertySections/PropsSection.ts index 78673eb6e..f19dfcf26 100644 --- a/frontend/src/components/BlockPropertySections/PropsSection.ts +++ b/frontend/src/components/BlockPropertySections/PropsSection.ts @@ -23,9 +23,8 @@ const propsSection = [ "update:obj": (obj: BlockProps) => blockController.setBlockProps(obj), // TODO: race condition? "update:ancestorUpdateDependency": ( propKey: string, - ancestorBlockId: string, action: "add" | "remove", // TODO: better name? - ) => blockController.updateBlockPropsDependencyForAncestor(propKey, ancestorBlockId, action), + ) => blockController.updateBlockPropsDependencyForAncestor(propKey, action), }, }, ]; diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue index b30ab2a4e..0a16f779d 100644 --- a/frontend/src/components/PropsEditor.vue +++ b/frontend/src/components/PropsEditor.vue @@ -24,13 +24,7 @@ :getOptions="getOptions" @update:modelValue=" (option) => { - (autoCompleteRef?.value?.[index] as any)?.blur(); - updateObjectValue( - key, - typeof option == 'string' - ? `static--${blockController.getFirstSelectedBlock()?.blockId}--${option}` - : option?.value, - ); + updateObjectValue(key, typeof option == 'string' ? option : option?.value); } "> @@ -117,7 +136,17 @@ import LucideCaseSensitive from "~icons/lucide/case-sensitive"; // @ts-ignore import LucideListTree from "~icons/lucide/list-tree"; // @ts-ignore -import LucideChevronsLeftRightEllipsis from "~icons/lucide/chevrons-left-right-ellipsis"; +import LucideNumber from "~icons/lucide/pi"; +// @ts-ignore +import LucideString from "~icons/lucide/type"; +// @ts-ignore +import LucideBoolean from "~icons/lucide/toggle-right"; +// @ts-ignore +import LucideSelect from "~icons/lucide/chevrons-up-down"; +// @ts-ignore +import LucideArray from "~icons/lucide/brackets"; +// @ts-ignore +import LucideObject from "~icons/lucide/braces"; import { Popover } from "frappe-ui"; import PropsPopoverContent from "./PropsPopoverContent.vue"; @@ -174,8 +203,7 @@ const addProp = async (name: string, value: BlockProps[string]) => { return map; }; -const clearObjectValue = (key: string) => { - const map = new Map(Object.entries(props.obj)); +const clearObjectValue = (map: Map, key: string) => { const oldValue = map.get(key); map.set(key, { @@ -198,7 +226,7 @@ const updateObjectValue = ( ) => { const path = value; if (!path) { - return clearObjectValue(key); + return clearObjectValue(map, key); } const oldPath = map.get(key)?.value; diff --git a/frontend/src/components/PropsOptions/ArrayOptions.vue b/frontend/src/components/PropsOptions/ArrayOptions.vue index a302b55fa..3e50133ed 100644 --- a/frontend/src/components/PropsOptions/ArrayOptions.vue +++ b/frontend/src/components/PropsOptions/ArrayOptions.vue @@ -1,15 +1,23 @@ @@ -17,7 +25,120 @@ import InputLabel from "@/components/Controls/InputLabel.vue"; import Input from "@/components/Controls/Input.vue"; import ArrayEditor from "@/components/ArrayEditor.vue"; -import { ref } from "vue"; +import { nextTick, ref, watch } from "vue"; +import { Ref } from "vue"; +import { toast } from "vue-sonner"; -const arr = ref([]); +const props = defineProps<{ + options: Record; +}>(); + +const emit = defineEmits<{ + (update: "update:options", value: Record): void; +}>(); + +type NumberRef = { + value: Ref; + handleChange: (val: string) => Promise; + reset: (toProps?: boolean) => void; +}; + +type StringArrayRef = { + value: Ref; + handleChange: (val: any[]) => Promise; + reset: (toProps?: boolean) => void; +}; + +function toNumberOrNull(v: any) { + const n = parseFloat(v); + return Number.isFinite(n) ? n : null; +} + +function performValidation() { + const min = toNumberOrNull(minItems.value); + const max = toNumberOrNull(maxItems.value); + const def = arr.value.length; + let isValid = true; + if (min !== null && max !== null && min > max) { + isValid = false; + } + if (def) { + if (min !== null && def < min) { + isValid = false; + } + if (max !== null && def > max) { + isValid = false; + } + } + return isValid; +} + +function useArrayOption(key: string, isNumeric: boolean = false) { + const numericValue = ref(toNumberOrNull(props.options?.[key])); + const arrayValue = ref(Array.isArray(props.options?.[key]) ? props.options?.[key] : []); + + watch( + () => props.options?.[key], + (newVal) => { + if (isNumeric) { + numericValue.value = toNumberOrNull(newVal); + } else { + arrayValue.value = Array.isArray(newVal) ? newVal : []; + } + }, + ); + + function resetNumber(toProps: boolean) { + numericValue.value = toProps ? toNumberOrNull(props.options?.[key]) : null; + } + function resetArray(toProps: boolean) { + arrayValue.value = toProps && Array.isArray(props.options?.[key]) ? props.options?.[key] : []; + } + + async function handleNumberChange(val: string) { + numericValue.value = toNumberOrNull(val); + await nextTick(); + const isValid = performValidation(); + if (isValid) { + emit("update:options", { + minItems, + maxItems, + defaultValue: arr, + }); + } else { + toast.error("Invalid option configuration!"); + } + } + + async function handleArrayChange(val: any[]) { + arrayValue.value = val; + await nextTick(); + const isValid = performValidation(); + if (isValid) { + emit("update:options", { + minItems: minItems.value, + maxItems: maxItems.value, + defaultValue: arr.value, + }); + } else { + toast.error("Invalid option configuration!"); + } + } + + return isNumeric + ? { value: numericValue, handleChange: handleNumberChange, reset: resetNumber } + : { value: arrayValue, handleChange: handleArrayChange, reset: resetArray }; +} + +const { value: minItems, handleChange: handleMinItemsChange, reset: resetMin } = useArrayOption("minItems", true) as NumberRef; +const { value: maxItems, handleChange: handleMaxItemsChange, reset: resetMax } = useArrayOption("maxItems", true) as NumberRef; +const { value: arr, handleChange: handleArrChange, reset: resetArr } = useArrayOption("defaultValue") as StringArrayRef; + +const reset = (toProps: boolean = false) => { + resetMin(toProps); + resetMax(toProps); + resetArr(toProps); +}; + +defineExpose({ reset }); diff --git a/frontend/src/components/PropsOptions/BooleanOptions.vue b/frontend/src/components/PropsOptions/BooleanOptions.vue index f4c02d9f1..e2ddee18c 100644 --- a/frontend/src/components/PropsOptions/BooleanOptions.vue +++ b/frontend/src/components/PropsOptions/BooleanOptions.vue @@ -1,29 +1,88 @@ diff --git a/frontend/src/components/PropsOptions/NumberOptions.vue b/frontend/src/components/PropsOptions/NumberOptions.vue index cebcaa999..096b77a37 100644 --- a/frontend/src/components/PropsOptions/NumberOptions.vue +++ b/frontend/src/components/PropsOptions/NumberOptions.vue @@ -1,19 +1,117 @@ diff --git a/frontend/src/components/PropsOptions/ObjectOptions.vue b/frontend/src/components/PropsOptions/ObjectOptions.vue index bce1c908c..eb4839fc6 100644 --- a/frontend/src/components/PropsOptions/ObjectOptions.vue +++ b/frontend/src/components/PropsOptions/ObjectOptions.vue @@ -1,23 +1,157 @@ diff --git a/frontend/src/components/PropsOptions/SelectOptions.vue b/frontend/src/components/PropsOptions/SelectOptions.vue new file mode 100644 index 000000000..e7442828c --- /dev/null +++ b/frontend/src/components/PropsOptions/SelectOptions.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/components/PropsOptions/StringOptions.vue b/frontend/src/components/PropsOptions/StringOptions.vue index b1e871e71..306a63b75 100644 --- a/frontend/src/components/PropsOptions/StringOptions.vue +++ b/frontend/src/components/PropsOptions/StringOptions.vue @@ -1,11 +1,38 @@ diff --git a/frontend/src/components/PropsPopoverContent.vue b/frontend/src/components/PropsPopoverContent.vue index bff2bc4a8..e63208f4b 100644 --- a/frontend/src/components/PropsPopoverContent.vue +++ b/frontend/src/components/PropsPopoverContent.vue @@ -24,6 +24,11 @@
@@ -141,7 +151,7 @@ import OptionToggle from "@/components/Controls/OptionToggle.vue"; import BuilderButton from "@/components/Controls/BuilderButton.vue"; import Autocomplete from "@/components/Controls/Autocomplete.vue"; -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; +import { computed, nextTick, reactive, ref, watch } from "vue"; import PropsDependencyEditor from "@/components/PropsDependencyEditor.vue"; import useCanvasStore from "@/stores/canvasStore"; @@ -152,6 +162,7 @@ import StringOptions from "@/components/PropsOptions/StringOptions.vue"; import ArrayOptions from "@/components/PropsOptions/ArrayOptions.vue"; import ObjectOptions from "@/components/PropsOptions/ObjectOptions.vue"; import BooleanOptions from "@/components/PropsOptions/BooleanOptions.vue"; +import SelectOptions from "@/components/PropsOptions/SelectOptions.vue"; import usePageStore from "@/stores/pageStore"; import { getCollectionKeys, getDataForKey } from "@/utils/helpers"; @@ -177,7 +188,7 @@ const value = ref(props.propDetails?.value ?? ""); const selectedNonStandardPropType = ref( props.propDetails && !props.propDetails.isStandard ? props.propDetails.type : "static", ); -const standardPropOptions = ref( +const standardPropOptions = reactive( props.propDetails && props.propDetails.isStandard ? { ...props.propDetails.standardOptions, @@ -192,12 +203,13 @@ const standardPropOptions = ref( dependencies: {}, }, ); -const standardPropDependencyMap = ref<{ [key: string]: any }>( +const standardPropDependencyMap = reactive<{ [key: string]: any }>( props.propDetails && props.propDetails.isStandard ? props.propDetails.standardOptions?.dependencies || {} : {}, ); const autoCompleteRef = ref(null); +const optionsComponentRef = ref(null); const emit = defineEmits({ "add:prop": (name: string, prop: BlockProps[string]) => true, @@ -219,6 +231,10 @@ const propTypes = computed(() => { label: "Boolean", value: "boolean", }, + { + label: "Select", + value: "select", + }, { label: "Array", value: "array", @@ -256,18 +272,17 @@ const isInFragmentMode = computed(() => { const selectedPropType = computed(() => { if (isStandardBool.value) { - console.log("standardPropOptions.value.type", standardPropOptions.value.type); - return standardPropOptions.value.type; + return standardPropOptions.type; } else { return selectedNonStandardPropType.value; } }); -const STANDARD_PROP_TYPES = ["string", "number", "boolean", "array", "object"]; +const STANDARD_PROP_TYPES = ["string", "number", "boolean", "select", "array", "object"]; const setPropType = (type: string) => { if (isStandardBool.value && STANDARD_PROP_TYPES.includes(type)) { - standardPropOptions.value.type = type as "string" | "number" | "boolean" | "array" | "object"; + standardPropOptions.type = type as "string" | "number" | "boolean" | "select" | "array" | "object"; } else { selectedNonStandardPropType.value = type as "static" | "inherited" | "dynamic"; } @@ -336,8 +351,9 @@ const getOptions = async (query: string) => { const componentMapping = { string: StringOptions, number: NumberOptions, - array: ArrayOptions, boolean: BooleanOptions, + select: SelectOptions, + array: ArrayOptions, object: ObjectOptions, }; @@ -370,23 +386,47 @@ const reset = async (keepParams: { keepType: boolean; }) => { const { keepName, keepIsStandard, keepProps, keepType } = keepParams; + + const details = keepProps ? props.propDetails ?? null : null; + if (!keepName) name.value = props.propName ?? ""; - const propDetails = keepProps ? props.propDetails : null; - if (!keepIsStandard) isStandard.value = propDetails?.isStandard ? "true" : "false"; - value.value = propDetails?.value ?? ""; - console.trace("val", keepParams, propDetails?.value ?? "hello"); - if (propDetails?.isStandard) { - standardPropOptions.value = { - ...propDetails.standardOptions, - isRequired: Boolean(propDetails.standardOptions?.isRequired), - ...(keepType - ? { type: standardPropOptions.value.type } - : { type: propDetails.standardOptions?.type || "string" }), - }; - standardPropDependencyMap.value = propDetails.standardOptions?.dependencies || {}; + if (!keepIsStandard) isStandard.value = details?.isStandard ? "true" : "false"; + + value.value = details?.value ?? ""; + + if (details?.isStandard) { + const nextType = keepType ? standardPropOptions.type : details?.standardOptions?.type ?? "string"; + + Object.assign(standardPropOptions, { + ...details?.standardOptions, + isRequired: Boolean(details?.standardOptions?.isRequired), + type: nextType, + }); + + const deps = details?.standardOptions?.dependencies ?? {}; + Object.keys(standardPropDependencyMap).forEach((k) => delete standardPropDependencyMap[k]); + Object.assign(standardPropDependencyMap, deps); + } else if (isStandardBool.value) { + if (!keepType) { + standardPropOptions.type = "string"; + } + + Object.assign(standardPropOptions, { + isRequired: false, + type: standardPropOptions.type, + defaultValue: "", + options: [], + dependencies: {}, + }); + + Object.assign(standardPropDependencyMap, {}); } else { - if (!keepType) selectedNonStandardPropType.value = propDetails?.type || "static"; + if (!keepType) { + selectedNonStandardPropType.value = details?.type || "static"; + } } + + optionsComponentRef.value?.reset(!!keepProps); }; watch( @@ -416,11 +456,11 @@ const save = async () => { await nextTick(); const propValue: BlockProps[string] = { isStandard: isStandardBool.value, - type: isStandardBool.value ? "static" : selectedNonStandardPropType.value, + type: isStandardBool.value ? "static" : selectedNonStandardPropType.value, // all standard props are static by default value: isStandardBool.value ? null : value.value, standardOptions: isStandardBool.value ? { - ...standardPropOptions.value, + ...standardPropOptions, dependencies: standardPropDependencyMap.value, } : undefined, @@ -431,6 +471,5 @@ const save = async () => { emit("update:prop", props.propName!, name.value, propValue); } }; - defineExpose({ reset }); From 9eaae3900c1b94e7d06e16198a767cef727f0cae Mon Sep 17 00:00:00 2001 From: sntn Date: Sun, 16 Nov 2025 19:25:40 +0530 Subject: [PATCH 056/547] fix: logic and labels for diff options --- .../PropsOptions/BooleanOptions.vue | 34 +++++++++---------- .../components/PropsOptions/SelectOptions.vue | 28 ++++++++------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/PropsOptions/BooleanOptions.vue b/frontend/src/components/PropsOptions/BooleanOptions.vue index e2ddee18c..95b45d859 100644 --- a/frontend/src/components/PropsOptions/BooleanOptions.vue +++ b/frontend/src/components/PropsOptions/BooleanOptions.vue @@ -1,19 +1,19 @@ @@ -59,13 +63,19 @@ function useSelectOption(key: string, isString: boolean = false) { async function handleStringChange(val: string) { stringValue.value = val; - emit("update:options", {}); + emit("update:options", { + options: options.value, + defaultValue: defaultValue.value, + }); } async function handleArrayChange(val: any[]) { arrayValue.value = val; await nextTick(); - emit("update:options", {}); + emit("update:options", { + options: options.value, + defaultValue: defaultValue.value, + }); } return isString @@ -73,16 +83,8 @@ function useSelectOption(key: string, isString: boolean = false) { : { value: arrayValue, handleChange: handleArrayChange, reset: resetArray }; } -const { - value: options, - handleChange: handleOptionsChange, - reset: resetOptions, -} = useSelectOption("options") as StringArrayRef; -const { - value: defaultValue, - handleChange: handleDefaultValueChange, - reset: resetDefaultValue, -} = useSelectOption("defaultValue", true) as StringRef; +const { value: options, handleChange: handleOptionsChange, reset: resetOptions } = useSelectOption("options") as StringArrayRef; +const { value: defaultValue, handleChange: handleDefaultValueChange, reset: resetDefaultValue } = useSelectOption("defaultValue", true) as StringRef; const optionsAvailable = computed(() => { return options.value.map((opt) => ({ label: opt, value: opt })); From b99ffcd4252e21d61b8e512e04674e61a6f21ea0 Mon Sep 17 00:00:00 2001 From: Sydney Gomes <67939367+aerodeval@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:51:44 +0530 Subject: [PATCH 057/547] feat: Add Accessibility section (#431) Co-authored-by: Suraj Shetty --- frontend/src/components/BlockProperties.vue | 2 + .../AccessibilitySection.ts | 148 ++++++++++++++++++ .../BlockPropertySections/OptionsSection.ts | 72 +-------- 3 files changed, 151 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/BlockPropertySections/AccessibilitySection.ts diff --git a/frontend/src/components/BlockProperties.vue b/frontend/src/components/BlockProperties.vue index 52ed0d380..d2f97b44c 100644 --- a/frontend/src/components/BlockProperties.vue +++ b/frontend/src/components/BlockProperties.vue @@ -32,6 +32,7 @@
diff --git a/frontend/src/components/BlockProperties.vue b/frontend/src/components/BlockProperties.vue index f6f4fa9c6..df799c9f9 100644 --- a/frontend/src/components/BlockProperties.vue +++ b/frontend/src/components/BlockProperties.vue @@ -13,6 +13,17 @@ " />
+ + + & { + properties: () => BlockProperty[]; +}; + const searchInput = ref(null) as Ref; const showSection = (section: PropertySection) => { @@ -104,6 +120,22 @@ const getFilteredProperties = (section: PropertySection) => { }); }; +const getFilteredPropertiesDynamic = (section: PropertySectionDynamic) => { + const staticSection: PropertySection = { + ...section, + properties: section.properties(), + }; + return getFilteredProperties(staticSection); +}; + +const showSectionDynamic = (section: PropertySectionDynamic) => { + const staticSection: PropertySection = { + ...section, + properties: section.properties(), + }; + return showSection(staticSection); +}; + const sections = [ collectionOptionsSection, linkSection, diff --git a/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts new file mode 100644 index 000000000..1c2104739 --- /dev/null +++ b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts @@ -0,0 +1,94 @@ +import PropsEditor from "../PropsEditor.vue"; +import blockController from "@/utils/blockController"; +import OptionToggle from "../Controls/OptionToggle.vue"; +import PropertyControl from "../Controls/PropertyControl.vue"; +import ArrayInput from "../ArrayInput.vue"; + +const componentMap = { + string: PropertyControl, + number: PropertyControl, + boolean: PropertyControl, + select: PropertyControl, + array: ArrayInput, + object: PropsEditor, +}; + +const getPropsMap = (propName: string, propDetails: BlockProps[string]) => { + const type = propDetails.standardOptions?.type || "string"; + let map = {}; + switch (type) { + case "string": + case "number": + map = { + enableStates: false, + }; + break; + case "boolean": + map = { + component: OptionToggle, + enableStates: false, + options: [ + { label: propDetails.standardOptions?.options?.trueLabel || "True", value: true }, + { label: propDetails.standardOptions?.options?.falseLabel || "False", value: false }, + ], + }; + break; + case "select": + map = { + type: "select", + enableStates: false, + options: + propDetails.standardOptions?.options?.options?.map((item: any) => ({ + label: item, + value: item, + })) || [], + }; + break; + } + map = { + label: propName, + ...map, + }; + return map; +}; + +const getStandardProps = (allProps: BlockProps) => { + console.log("all props: ", allProps); + const standardProps: BlockProps = {}; + for (const [propKey, propDetails] of Object.entries(allProps || {})) { + if (propDetails.isStandard) { + standardProps[propKey] = propDetails; + } + } + console.log("standard props: ", standardProps); + return standardProps; +}; + +const getStandardPropsInputSection = () => { + const standardProps = getStandardProps(blockController.getBlockProps()); + console; + const sections = []; + for (const [propKey, propDetails] of Object.entries(standardProps)) { + console.log({ propKey, propDetails }); + const component = componentMap[propDetails.standardOptions?.type || "string"] || PropertyControl; + const getProps = () => { + const props = getPropsMap(propKey, propDetails); + console.log("props for standard prop input: ", props); + return props; + }; + sections.push({ + component, + getProps, + searchKeyWords: propKey, + }); + } + console.log("standard props sections: ", sections); + return sections; +}; + +export default { + name: "Standard Props", + properties: getStandardPropsInputSection, + collapsed: false, + condition: () => blockController.getFirstSelectedBlock().isExtendedFromComponent(), +}; diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue index 0bfd546e1..d85ccb7c5 100644 --- a/frontend/src/components/PropsEditor.vue +++ b/frontend/src/components/PropsEditor.vue @@ -38,8 +38,7 @@

{{ key }} -

* - +

Std. - {{ value.standardOptions?.isRequired ? "Required" : "Optional" }}

@@ -172,7 +171,7 @@ const props = defineProps<{ const sortedObj = computed(() => { // Sort props: standard props at the front, then non-standard props - const entries = Object.entries(props.obj); + const entries = Object.entries(props.obj || {}); entries.sort((a, b) => { const aIsStandard = a[1].isStandard ? 1 : 0; const bIsStandard = b[1].isStandard ? 1 : 0; diff --git a/frontend/src/components/PropsPopoverContent.vue b/frontend/src/components/PropsPopoverContent.vue index e63208f4b..d9d75660b 100644 --- a/frontend/src/components/PropsPopoverContent.vue +++ b/frontend/src/components/PropsPopoverContent.vue @@ -114,15 +114,10 @@
{ + standardPropOptions.options = options; }" />
diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index eae5cf151..e2af83a3f 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -35,6 +35,6 @@ export default defineConfig({ allowedHosts: true, }, optimizeDeps: { - include: ["frappe-ui > feather-icons", "showdown", "engine.io-client"], + include: ["frappe-ui > feather-icons", "showdown", "engine.io-client", "highlight.js/lib/core"], }, }); From c37cbb7d6eeae878afe57f49df48e4d351304ff3 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 17 Nov 2025 08:44:17 +0530 Subject: [PATCH 059/547] refactor: Move export/import code to a separate file --- .../doctype/builder_page/builder_page.py | 57 +----------- builder/export_import_standard_page.py | 92 +++++++++++++++++++ builder/install.py | 3 +- builder/utils.py | 30 ------ 4 files changed, 95 insertions(+), 87 deletions(-) create mode 100644 builder/export_import_standard_page.py diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 4edb54b14..d5944c42a 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -9,7 +9,7 @@ import frappe import frappe.utils from frappe.modules import scrub -from frappe.modules.export_file import export_to_files, strip_default_fields +from frappe.modules.export_file import export_to_files from frappe.utils import set_request from frappe.utils.caching import redis_cache from frappe.utils.jinja import render_template @@ -21,6 +21,7 @@ from frappe.website.website_generator import WebsiteGenerator from jinja2.exceptions import TemplateSyntaxError +from builder.export_import_standard_page import export_page_as_standard from builder.hooks import builder_path from builder.html_preview_image import generate_preview from builder.utils import ( @@ -28,15 +29,9 @@ ColonRule, camel_case_to_kebab_case, clean_data, - copy_asset_file, - copy_assets_from_blocks, copy_img_to_asset_folder, - create_export_directories, escape_single_quotes, execute_script, - export_client_scripts, - export_components, - extract_components_from_blocks, get_builder_page_preview_file_paths, get_template_assets_folder_path, is_component_used, @@ -852,54 +847,6 @@ def reset_block(block): return block -def export_page_as_standard(page_name, target_app, export_name=None): - """Export a builder page as standard files to the specified app""" - page_doc = frappe.get_doc("Builder Page", page_name) - if not export_name: - export_name = page_doc.page_name or page_name - - # Clean the export name to be filesystem-safe - export_name = frappe.scrub(export_name) - - # Get app path - app_path = frappe.get_app_path(target_app) - if not app_path: - frappe.throw(f"App '{target_app}' not found") - - # Create directories and get paths - paths = create_export_directories(app_path, export_name) - - page_config = page_doc.as_dict(no_nulls=True) - page_config = strip_default_fields(page_doc, page_config) - - config_file_path = os.path.join(paths["page_path"], f"{export_name}.json") - - blocks = frappe.parse_json(page_config.get("draft_blocks") or page_config["blocks"]) - if blocks: - copy_assets_from_blocks(blocks, paths["assets_path"]) - page_config["blocks"] = blocks - page_config["draft_blocks"] = None - - if page_doc.favicon: - page_config["favicon"] = copy_asset_file(page_doc.favicon, paths["assets_path"]) - if page_doc.meta_image: - page_config["meta_image"] = copy_asset_file(page_doc.meta_image, paths["assets_path"]) - - page_config["project_folder"] = target_app - page_config = frappe.as_json(page_config, ensure_ascii=False) - - with open(config_file_path, "w", encoding="utf-8") as f: - f.write(page_config) - - # Export client scripts - export_client_scripts(page_doc, paths["client_scripts_path"]) - - # # Export components used in the page - if blocks: - components = extract_components_from_blocks(blocks) - export_components(components, paths["components_path"], paths["assets_path"]) - - @frappe.whitelist() def duplicate_standard_page(app_name, page_folder_name, new_page_name=None): """Duplicate a standard page into a new builder page""" diff --git a/builder/export_import_standard_page.py b/builder/export_import_standard_page.py new file mode 100644 index 000000000..3dd981eae --- /dev/null +++ b/builder/export_import_standard_page.py @@ -0,0 +1,92 @@ +import os + +import frappe +from frappe.modules.export_file import strip_default_fields + +from builder.utils import ( + copy_asset_file, + copy_assets_from_blocks, + create_export_directories, + export_client_scripts, + export_components, + extract_components_from_blocks, + make_records, +) + + +def export_page_as_standard(page_name, target_app, export_name=None): + """Export a builder page as standard files to the specified app""" + page_doc = frappe.get_doc("Builder Page", page_name) + if not export_name: + export_name = page_doc.page_name or page_name + + # Clean the export name to be filesystem-safe + export_name = frappe.scrub(export_name) + + # Get app path + app_path = frappe.get_app_path(target_app) + if not app_path: + frappe.throw(f"App '{target_app}' not found") + + # Create directories and get paths + paths = create_export_directories(app_path, export_name) + + page_config = page_doc.as_dict(no_nulls=True) + page_config = strip_default_fields(page_doc, page_config) + + config_file_path = os.path.join(paths["page_path"], f"{export_name}.json") + + blocks = frappe.parse_json(page_config.get("draft_blocks") or page_config["blocks"]) + if blocks: + copy_assets_from_blocks(blocks, paths["assets_path"]) + page_config["blocks"] = blocks + page_config["draft_blocks"] = None + + if page_doc.favicon: + page_config["favicon"] = copy_asset_file(page_doc.favicon, paths["assets_path"]) + if page_doc.meta_image: + page_config["meta_image"] = copy_asset_file(page_doc.meta_image, paths["assets_path"]) + + page_config["project_folder"] = target_app + page_config = frappe.as_json(page_config, ensure_ascii=False) + + with open(config_file_path, "w", encoding="utf-8") as f: + f.write(page_config) + + # Export client scripts + export_client_scripts(page_doc, paths["client_scripts_path"]) + + # # Export components used in the page + if blocks: + components = extract_components_from_blocks(blocks) + export_components(components, paths["components_path"], paths["assets_path"]) + + +def sync_standard_builder_pages(app_name=None): + print("Syncing Standard Builder Pages") + # fetch pages from all apps under builder_files/pages + # import components first + + apps_to_sync = [app_name] if app_name else frappe.get_installed_apps() + + for app in apps_to_sync: + app_path = frappe.get_app_path(app) + pages_path = os.path.join(app_path, "builder_files", "pages") + components_path = os.path.join(app_path, "builder_files", "components") + scripts_path = os.path.join(app_path, "builder_files", "client_scripts") + if os.path.exists(components_path): + print(f"Importing components from {components_path}") + make_records(components_path) + if os.path.exists(scripts_path): + print(f"Importing scripts from {scripts_path}") + make_records(scripts_path) + if os.path.exists(pages_path): + frappe.get_doc( + { + "doctype": "Builder Project Folder", + "folder_name": app, + "is_standard": 1, + } + ).insert(ignore_if_duplicate=True) + print(f"Importing page from {pages_path}") + make_records(pages_path) diff --git a/builder/install.py b/builder/install.py index 726f6a933..19e58352e 100644 --- a/builder/install.py +++ b/builder/install.py @@ -1,12 +1,11 @@ -import frappe from frappe.core.api.file import create_new_folder +from builder.export_import_standard_page import sync_standard_builder_pages from builder.utils import ( add_composite_index_to_web_page_view, sync_block_templates, sync_builder_variables, sync_page_templates, - sync_standard_builder_pages, ) diff --git a/builder/utils.py b/builder/utils.py index 0ba9ad8d1..9a35a8c3d 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -609,33 +609,3 @@ def remove_existing_path(path): shutil.rmtree(path) else: os.remove(path) - - -def sync_standard_builder_pages(app_name=None): - print("Syncing Standard Builder Pages") - # fetch pages from all apps under builder_files/pages - # import components first - - apps_to_sync = [app_name] if app_name else frappe.get_installed_apps() - - for app in apps_to_sync: - app_path = frappe.get_app_path(app) - pages_path = os.path.join(app_path, "builder_files", "pages") - components_path = os.path.join(app_path, "builder_files", "components") - scripts_path = os.path.join(app_path, "builder_files", "client_scripts") - if os.path.exists(components_path): - print(f"Importing components from {components_path}") - make_records(components_path) - if os.path.exists(scripts_path): - print(f"Importing scripts from {scripts_path}") - make_records(scripts_path) - if os.path.exists(pages_path): - frappe.get_doc( - { - "doctype": "Builder Project Folder", - "folder_name": app, - "is_standard": 1, - } - ).insert(ignore_if_duplicate=True) - print(f"Importing page from {pages_path}") - make_records(pages_path) From afdd01446aef7281e1f562d34ee7fc7db2af537f Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 17 Nov 2025 09:01:45 +0530 Subject: [PATCH 060/547] fix: Properly set developer_mode --- frontend/index.html | 2 +- frontend/src/main.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index d4849fa0d..167f4d76f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@ window.builder_path = "{{ builder_path }}"; window.site_name = "{{ site_name }}"; window.is_fc_site = "{{ is_fc_site }}"; - window.is_developer_mode = Boolean("{{ is_developer_mode }}"); + window.is_developer_mode = "{{ is_developer_mode }}"; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 90a6219f3..93b0bc690 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -35,3 +35,10 @@ declare global { is_developer_mode?: boolean; } } + +if (window.is_developer_mode && typeof window.is_developer_mode === "string") { + window.is_developer_mode = + window.is_developer_mode === "1" || + window.is_developer_mode === "True" || + (window.is_developer_mode as string).startsWith("{{"); +} From 4ec424a27905f2f436919b941b038bcf00815c01 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 17 Nov 2025 10:33:12 +0530 Subject: [PATCH 061/547] refactor: Simplify --- builder/export_import_standard_page.py | 14 ++------------ builder/utils.py | 6 +----- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/builder/export_import_standard_page.py b/builder/export_import_standard_page.py index 3dd981eae..5c9546702 100644 --- a/builder/export_import_standard_page.py +++ b/builder/export_import_standard_page.py @@ -14,21 +14,15 @@ ) -def export_page_as_standard(page_name, target_app, export_name=None): +def export_page_as_standard(page_name, target_app): """Export a builder page as standard files to the specified app""" page_doc = frappe.get_doc("Builder Page", page_name) - if not export_name: - export_name = page_doc.page_name or page_name + export_name = frappe.scrub(page_doc.page_name) - # Clean the export name to be filesystem-safe - export_name = frappe.scrub(export_name) - - # Get app path app_path = frappe.get_app_path(target_app) if not app_path: frappe.throw(f"App '{target_app}' not found") - # Create directories and get paths paths = create_export_directories(app_path, export_name) page_config = page_doc.as_dict(no_nulls=True) @@ -53,10 +47,8 @@ def export_page_as_standard(page_name, target_app, export_name=None): with open(config_file_path, "w", encoding="utf-8") as f: f.write(page_config) - # Export client scripts export_client_scripts(page_doc, paths["client_scripts_path"]) - # # Export components used in the page if blocks: components = extract_components_from_blocks(blocks) export_components(components, paths["components_path"], paths["assets_path"]) @@ -64,8 +56,6 @@ def export_page_as_standard(page_name, target_app, export_name=None): def sync_standard_builder_pages(app_name=None): print("Syncing Standard Builder Pages") - # fetch pages from all apps under builder_files/pages - # import components first apps_to_sync = [app_name] if app_name else frappe.get_installed_apps() diff --git a/builder/utils.py b/builder/utils.py index 9a35a8c3d..7b245b535 100644 --- a/builder/utils.py +++ b/builder/utils.py @@ -1,6 +1,4 @@ -import glob import inspect -import json import os import re import shutil @@ -10,9 +8,8 @@ from urllib.parse import unquote, urlparse import frappe -from frappe.modules.export_file import export_to_files from frappe.modules.import_file import import_file_by_path -from frappe.utils import get_site_base_path, get_site_path, get_url +from frappe.utils import get_url from frappe.utils.safe_exec import ( SERVER_SCRIPT_FILE_PREFIX, FrappeTransformer, @@ -570,7 +567,6 @@ def export_components(components, components_path, assets_path): def create_export_directories(app_path, export_name): - """Create necessary directories for export and return paths""" paths = get_export_paths(app_path, export_name) setup_assets_symlink(app_path, paths["assets_path"]) for path in paths.values(): From 6ac5320446f325e3e556dbe55b4b261f0249764c Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Wed, 19 Nov 2025 10:36:34 +0530 Subject: [PATCH 062/547] fix: changing std. props now reflects on json --- .../StandardPropsInputSection.ts | 11 ++++++++++- frontend/src/utils/blockController.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts index 1c2104739..553716ea8 100644 --- a/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts +++ b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts @@ -47,6 +47,13 @@ const getPropsMap = (propName: string, propDetails: BlockProps[string]) => { } map = { label: propName, + setModelValue: (val: any) => { + blockController.setBlockProp(propName, val); + }, + getModelValue: () => { + const value = blockController.getFirstSelectedBlock().props?.[propName]?.value; + return value; + }, ...map, }; return map; @@ -90,5 +97,7 @@ export default { name: "Standard Props", properties: getStandardPropsInputSection, collapsed: false, - condition: () => blockController.getFirstSelectedBlock().isExtendedFromComponent(), + condition: () => + Boolean(blockController.getFirstSelectedBlock().extendedFromComponent) && + Object.keys(getStandardProps(blockController.getBlockProps())).length > 0, }; diff --git a/frontend/src/utils/blockController.ts b/frontend/src/utils/blockController.ts index b712f0608..aff565328 100644 --- a/frontend/src/utils/blockController.ts +++ b/frontend/src/utils/blockController.ts @@ -310,6 +310,18 @@ const blockController = { getBlockProps: () => { return blockController.getSelectedBlocks()[0]?.getBlockProps(); }, + setBlockProp: (key: string, value: string) => { + const allProps = blockController.getBlockProps(); + if(!allProps) return; + const updatedProps = { + ...allProps, + [key]: { + ...allProps[key], + value: value, + }, + }; + blockController.setBlockProps(updatedProps); + }, setBlockProps: (props: BlockProps) => { const block = blockController.getFirstSelectedBlock(); if (!block.props) { From fec31f32ec7ea14849c207b04eedefd9c10bd93f Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Wed, 19 Nov 2025 11:27:09 +0530 Subject: [PATCH 063/547] feat: added prop get and set logic to std. array input --- frontend/src/components/ArrayInput.vue | 35 ++++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/ArrayInput.vue b/frontend/src/components/ArrayInput.vue index 707f8a404..b45f5238b 100644 --- a/frontend/src/components/ArrayInput.vue +++ b/frontend/src/components/ArrayInput.vue @@ -26,11 +26,7 @@
+ @update:arr="updateModelValue" />
@@ -45,11 +41,34 @@ import BuilderButton from "./Controls/BuilderButton.vue"; const props = defineProps<{ label: string; - modelValue?: string[]; + getModelValue: () => string; + setModelValue: (value: string) => void; }>(); const emit = defineEmits({ - "update:modelValue": (value: string[]) => true, + "update:modelValue": (value: string) => true, }); -const arr = ref([]); + +const getPassedArray = () => { + try { + const value = props.getModelValue(); + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed; + } + return []; + } catch { + return []; + } +}; + +const arr = ref(getPassedArray()); + +const updateModelValue = (value: string[]) => { + arr.value = value; + props.setModelValue(JSON.stringify(value)); + emit("update:modelValue", JSON.stringify(value)); +}; + + From de6f3049d6460bf395048ab74424e4791347a0ed Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 20 Nov 2025 09:03:25 +0530 Subject: [PATCH 064/547] fix: Enable proxy for "desk" To make the dev environment compatible with V16 (https://github.com/frappe/frappe/releases/tag/v16.0.0-beta.1) --- frontend/vite.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index eae5cf151..5beecd95f 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -12,7 +12,7 @@ export default defineConfig({ frappeui({ frappeProxy: { port: 8080, - source: "^/(app|login|api|assets|files|pages|builder_assets)", + source: "^/(app|desk|login|api|assets|files|pages|builder_assets)", }, lucideIcons: true, }), From 709fce9dd150462386fc2f913c8025baac47112a Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 20 Nov 2025 11:09:26 +0530 Subject: [PATCH 065/547] refactor: Use reka-UI components to fix issues in Autocomplete --- .../src/components/Controls/Autocomplete.vue | 387 ++++++++++-------- .../src/components/Controls/ColorInput.vue | 2 +- 2 files changed, 214 insertions(+), 175 deletions(-) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index fcb470855..ad6fb0d51 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -1,235 +1,274 @@ diff --git a/frontend/src/components/Controls/ColorInput.vue b/frontend/src/components/Controls/ColorInput.vue index 993a48f4d..bc5c6b701 100644 --- a/frontend/src/components/Controls/ColorInput.vue +++ b/frontend/src/components/Controls/ColorInput.vue @@ -19,7 +19,7 @@ Date: Thu, 20 Nov 2025 13:07:59 +0530 Subject: [PATCH 066/547] fix: Do not auto select option on blur closes: https://github.com/frappe/builder/issues/433 --- .../src/components/Controls/Autocomplete.vue | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index ad6fb0d51..a5de973ff 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -19,9 +19,10 @@ autocomplete="off" @focus="handleFocus" @blur="handleBlur" + @change="handleBlur" @keydown="handleKeydown" :display-value="getDisplayValue" - :placeholder="placeholder" + :placeholder="isFocused ? '' : placeholder" class="h-full w-full flex-1 border-none bg-transparent px-0 text-base placeholder:text-ink-gray-4 focus:outline-none focus:ring-0" :class="{ 'pl-2': !$slots.prefix, @@ -46,7 +47,7 @@ {{ option.label }} Array.isArray(props.modelValue)); const hasValue = computed(() => { @@ -161,6 +163,10 @@ const filteredOptions = computed(() => { options = [{ label: searchQuery.value, value: searchQuery.value }, ...options]; } + if (!searchQuery.value && options.length > 0) { + options = [{ label: "", value: "_no_highlight_", disabled: true }, ...options]; + } + return options; }); @@ -201,13 +207,21 @@ const resetFlags = () => { const handleFocus = () => { resetFlags(); + isFocused.value = true; + refreshOptions(); emit("focus"); }; const handleBlur = () => { + isFocused.value = false; if (userCleared.value && !searchQuery.value) { preventSelection.value = true; isOpen.value = false; } + // If user has manually cleared the input (empty search query) and there's a current value, + // clear the selection to set it to empty + if (!searchQuery.value && hasValue.value) { + selectedValue.value = multiple.value ? [] : null; + } emit("blur"); }; const handleKeydown = (event: KeyboardEvent) => { From b83eaf2c91e9f49f8f4fac465308e271a0190190 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 20 Nov 2025 13:54:03 +0530 Subject: [PATCH 067/547] fix: Increase limit on fetching client script options --- frontend/src/components/PageClientScriptManager.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PageClientScriptManager.vue b/frontend/src/components/PageClientScriptManager.vue index 226f22f75..d54038aba 100644 --- a/frontend/src/components/PageClientScriptManager.vue +++ b/frontend/src/components/PageClientScriptManager.vue @@ -167,8 +167,8 @@ const attachedScriptResource = createListResource({ const clientScriptResource = createListResource({ doctype: "Builder Client Script", - fields: ["script", "script_type", "name"], - pageLength: 500, + fields: ["script_type", "name"], + pageLength: 10000, auto: true, }); From a640b06d713ca14730fd032eee59121b801389b9 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 20 Nov 2025 13:54:48 +0530 Subject: [PATCH 068/547] perf: Avoid redundant calls to fetch code completions --- .../components/Controls/CodeMirror/CodeMirrorEditor.vue | 9 ++------- frontend/src/data/codeCompletions.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 frontend/src/data/codeCompletions.ts diff --git a/frontend/src/components/Controls/CodeMirror/CodeMirrorEditor.vue b/frontend/src/components/Controls/CodeMirror/CodeMirrorEditor.vue index 7ac1e5621..64db88936 100644 --- a/frontend/src/components/Controls/CodeMirror/CodeMirrorEditor.vue +++ b/frontend/src/components/Controls/CodeMirror/CodeMirrorEditor.vue @@ -7,13 +7,13 @@ diff --git a/frontend/src/components/Controls/PropertyControl.vue b/frontend/src/components/Controls/PropertyControl.vue index 037691312..df182b15a 100644 --- a/frontend/src/components/Controls/PropertyControl.vue +++ b/frontend/src/components/Controls/PropertyControl.vue @@ -75,14 +75,15 @@
- - {{ dynamicValue }} + v-if="dynamicValue?.key"> + + + {{ dynamicValue.key }}
@@ -414,9 +415,10 @@ const disableStyle = (state: string) => { }); }; -function setDynamicValue(value: string) { +function setDynamicValue({ key, comesFrom }: { key: string; comesFrom?: BlockDataKey["comesFrom"] }) { + if (!comesFrom) comesFrom = "dataScript"; blockController.getSelectedBlocks().forEach((block) => { - block.setDynamicValue(props.styleProperty, props.controlType, value); + block.setDynamicValue(props.styleProperty, props.controlType, key, comesFrom); }); showDynamicValueModal.value = false; emit("setDynamicValue"); @@ -428,10 +430,14 @@ const dynamicValue = computed(() => { const dataKeyObj = blocks[0].dynamicValues.find((obj) => { return obj.type === props.controlType && obj.property === props.styleProperty; }); + if (dataKeyObj) { - return dataKeyObj.key; + return { + key: dataKeyObj.key || "", + comesFrom: dataKeyObj.comesFrom || ("dataScript" as BlockDataKey["comesFrom"]), + }; } else { - return ""; + return { key: "", comesFrom: "dataScript" as BlockDataKey["comesFrom"] }; } }); diff --git a/frontend/src/components/PropsEditor.vue b/frontend/src/components/PropsEditor.vue index d85ccb7c5..c5d40d40c 100644 --- a/frontend/src/components/PropsEditor.vue +++ b/frontend/src/components/PropsEditor.vue @@ -9,7 +9,7 @@ @@ -148,7 +135,6 @@ import Autocomplete from "@/components/Controls/Autocomplete.vue"; import { computed, nextTick, reactive, ref, watch } from "vue"; -import PropsDependencyEditor from "@/components/PropsDependencyEditor.vue"; import useCanvasStore from "@/stores/canvasStore"; import blockController from "@/utils/blockController"; @@ -167,6 +153,7 @@ const props = withDefaults( mode: "add" | "edit"; propName?: string | null; propDetails?: BlockProps[string] | null; + closePopover?: () => void; }>(), { mode: "add", From e8e142d33abeaea658e69101dc146c6ebac23cfc Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sat, 6 Dec 2025 23:51:39 +0530 Subject: [PATCH 106/547] fix: better uniform file names --- frontend/src/components/BlockProperties.vue | 4 ++-- .../{PropsSection.ts => BlockPropsSection.ts} | 4 ++-- .../{ScriptEditor.ts => BlockScriptSection.ts} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename frontend/src/components/BlockPropertySections/{PropsSection.ts => BlockPropsSection.ts} (93%) rename frontend/src/components/BlockPropertySections/{ScriptEditor.ts => BlockScriptSection.ts} (100%) diff --git a/frontend/src/components/BlockProperties.vue b/frontend/src/components/BlockProperties.vue index d43c6dd11..f4556a878 100644 --- a/frontend/src/components/BlockProperties.vue +++ b/frontend/src/components/BlockProperties.vue @@ -48,8 +48,8 @@ import styleSection from "@/components/BlockPropertySections/StyleSection"; import transitionSection from "@/components/BlockPropertySections/TransitionSection"; import typographySection from "@/components/BlockPropertySections/TypographySection"; import videoOptionsSection from "@/components/BlockPropertySections/VideoOptionsSection"; -import blockScriptSection from "@/components/BlockPropertySections/ScriptEditor"; -import blockPropsSection from "@/components/BlockPropertySections/PropsSection"; +import blockScriptSection from "@/components/BlockPropertySections/BlockScriptSection"; +import blockPropsSection from "@/components/BlockPropertySections/BlockPropsSection"; import standardPropsInputSection from "@/components/BlockPropertySections/StandardPropsInputSection"; import useBuilderStore from "@/stores/builderStore"; import blockController from "@/utils/blockController"; diff --git a/frontend/src/components/BlockPropertySections/PropsSection.ts b/frontend/src/components/BlockPropertySections/BlockPropsSection.ts similarity index 93% rename from frontend/src/components/BlockPropertySections/PropsSection.ts rename to frontend/src/components/BlockPropertySections/BlockPropsSection.ts index 1364556df..f5cba5ade 100644 --- a/frontend/src/components/BlockPropertySections/PropsSection.ts +++ b/frontend/src/components/BlockPropertySections/BlockPropsSection.ts @@ -2,7 +2,7 @@ import PropsEditor from "../PropsEditor.vue"; import blockController from "@/utils/blockController"; import { computed } from "vue"; -const propsSection = [ +const blockPropsSection = [ { component: PropsEditor, getProps: () => { @@ -27,7 +27,7 @@ const propsSection = [ export default { name: "Block Props", - properties: propsSection, + properties: blockPropsSection, collapsed: computed(() => { return Object.keys(blockController.getBlockProps()).length === 0; }), diff --git a/frontend/src/components/BlockPropertySections/ScriptEditor.ts b/frontend/src/components/BlockPropertySections/BlockScriptSection.ts similarity index 100% rename from frontend/src/components/BlockPropertySections/ScriptEditor.ts rename to frontend/src/components/BlockPropertySections/BlockScriptSection.ts From ce512a4c7d164b8b03040f50b4031835daecb287 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sat, 6 Dec 2025 23:57:31 +0530 Subject: [PATCH 107/547] refactor: removed console.log --- .../BlockPropertySections/StandardPropsInputSection.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts index d774fd248..a65584a73 100644 --- a/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts +++ b/frontend/src/components/BlockPropertySections/StandardPropsInputSection.ts @@ -60,27 +60,22 @@ const getPropsMap = (propName: string, propDetails: BlockProps[string]) => { }; const getStandardProps = (allProps: BlockProps) => { - console.log("all props: ", allProps); const standardProps: BlockProps = {}; for (const [propKey, propDetails] of Object.entries(allProps || {})) { if (propDetails.isStandard) { standardProps[propKey] = propDetails; } } - console.log("standard props: ", standardProps); return standardProps; }; const getStandardPropsInputSection = () => { const standardProps = getStandardProps(blockController.getBlockProps()); - console; const sections = []; for (const [propKey, propDetails] of Object.entries(standardProps)) { - console.log({ propKey, propDetails }); const component = componentMap[propDetails.standardOptions?.type || "string"] || PropertyControl; const getProps = () => { const props = getPropsMap(propKey, propDetails); - console.log("props for standard prop input: ", props); return props; }; sections.push({ @@ -89,7 +84,6 @@ const getStandardPropsInputSection = () => { searchKeyWords: propKey, }); } - console.log("standard props sections: ", sections); return sections; }; From 5dafdb5d62317ade5ba1c6ccd3adac5a14bd2194 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:16:03 +0530 Subject: [PATCH 108/547] fix: refactor getPropValue calls to use a dedicated data retrieval function --- frontend/src/components/BuilderBlock.vue | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index 8baa8ea92..ef241b2b4 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -178,12 +178,13 @@ const target = computed(() => { const styles = computed(() => { let dynamicStyles = {} as { [key: string]: string }; if (props.data || hasBlockProps.value) { + const getDataScriptValue = (path: string): any => { + return getDataForKey(props.data || {}, path); + }; if (props.block.getDataKey("type") === "style") { let value; if (props.block.getDataKey("comesFrom") === "props") { - value = getPropValue(props.block.getDataKey("key") as string, props.block, (path: string) => { - return getDataForKey(props.data as Object, path); - }, props.defaultProps); + value = getPropValue(props.block.getDataKey("key") as string, props.block, getDataScriptValue, props.defaultProps); } else { value = getDataForKey(props.data as Object, props.block.getDataKey("key") as string); } @@ -199,9 +200,7 @@ const styles = computed(() => { const property = dataKeyObj.property as string; let value; if (dataKeyObj.comesFrom === "props") { - value = getPropValue(dataKeyObj.key as string, props.block, (path: string) => { - return getDataForKey(props.data as Object, path); - }, props.defaultProps); + value = getPropValue(dataKeyObj.key as string, props.block, getDataScriptValue, props.defaultProps); } else { value = getDataForKey(props.data as Object, dataKeyObj.key as string); } From ebc5097cf4e58c7b742f05214e041446c3125178 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:22:10 +0530 Subject: [PATCH 109/547] fix: update saferExecuteBlockScript to use block UID instead of block ID --- frontend/src/components/BuilderBlock.vue | 4 ++-- frontend/src/utils/helpers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/BuilderBlock.vue b/frontend/src/components/BuilderBlock.vue index ef241b2b4..2edecc602 100644 --- a/frontend/src/components/BuilderBlock.vue +++ b/frontend/src/components/BuilderBlock.vue @@ -305,7 +305,7 @@ watch( ], () => { if (builderSettings.doc?.execute_block_scripts_in_editor) { - saferExecuteBlockScript(props.block.blockId, props.block.getBlockScript(), allResolvedProps.value); + saferExecuteBlockScript(uid, props.block.getBlockScript(), allResolvedProps.value); } }, { deep: true }, @@ -313,7 +313,7 @@ watch( onMounted(() => { if (builderSettings.doc?.execute_block_scripts_in_editor) { - saferExecuteBlockScript(props.block.blockId, props.block.getBlockScript(), allResolvedProps.value); + saferExecuteBlockScript(uid, props.block.getBlockScript(), allResolvedProps.value); } }); diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 9f31d19d8..4d828bf5f 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1007,7 +1007,7 @@ const getStandardPropValue = ( */ function saferExecuteBlockScript( - blockId: string, + blockUid: string, userScript: string, props: Record = {}, ) { @@ -1090,7 +1090,7 @@ function saferExecuteBlockScript( }; const sandboxRoot = document.querySelector("[data-block-id='root']") as HTMLElement; - const thisElement = document.querySelector(`[data-block-id='${blockId}']`) as HTMLElement; + const thisElement = document.querySelector(`[data-block-uid='${blockUid}']`) as HTMLElement; const proxiedRoot = wrap(sandboxRoot); const proxiedThis = wrap(thisElement); From 3701283feeb9b4cdff8135806b73840a05122702 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:25:50 +0530 Subject: [PATCH 110/547] refactor: remove console.log from getFixedStyles function --- frontend/src/components/Controls/Autocomplete.vue | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 93231b682..8089eb055 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -241,11 +241,6 @@ async function updateOptions() { // making it fixed makes it float above Popver container const getFixedStyles = () => { if (props.makeFixed && props.fixTo) { - console.log( - "calculating fixed styles", - comboboxInput.value, - comboboxInput.value?.$el.closest(props.fixTo), - ); const fixedToElRect = ( comboboxInput.value?.$el.closest(props.fixTo) as HTMLElement )?.getBoundingClientRect(); From 1d6f09adbfd9290aab36345d10b6a02437a314e5 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:40:49 +0530 Subject: [PATCH 111/547] refactor: remove console.log from getFixedStyles function --- frontend/src/components/Controls/Autocomplete.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 8089eb055..f96ab1ed6 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -245,7 +245,6 @@ const getFixedStyles = () => { comboboxInput.value?.$el.closest(props.fixTo) as HTMLElement )?.getBoundingClientRect(); const comboboxInputRect = comboboxInput.value?.$el.getBoundingClientRect(); - console.log({ fixedToElRect, comboboxInputRect }); if (!fixedToElRect || !comboboxInputRect) { return {}; } From 64149c79f2249fd1233916a45832dbed109f5d8d Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:49:27 +0530 Subject: [PATCH 112/547] refactor: removed unnecessary code from NewComponent Modal just cleaning the first block does not solve anything --- frontend/src/components/Modals/NewComponent.vue | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/frontend/src/components/Modals/NewComponent.vue b/frontend/src/components/Modals/NewComponent.vue index 4f07a0bf7..05f458941 100644 --- a/frontend/src/components/Modals/NewComponent.vue +++ b/frontend/src/components/Modals/NewComponent.vue @@ -53,19 +53,7 @@ const componentName = ref(""); const isGlobalComponent = ref(0); const createComponentHandler = async (context: { close: () => void }) => { - // clean block with inherited or dynamic props - const cleanBlock = props.block; - if (props.block.props) { - const cleanProps = cleanBlock.props || {}; - Object.entries(props.block.props).forEach(([key, value]) => { - if (value.type === 'inherited' || value.type === 'dynamic') { - cleanProps[key] = {...value, type: 'static' , value: null}; - } - }); - cleanBlock.props = cleanProps; - } - - const blockCopy = getBlockCopy(cleanBlock, true); + const blockCopy = getBlockCopy(props.block, true); blockCopy.removeStyle("left"); blockCopy.removeStyle("top"); blockCopy.removeStyle("position"); From 9a3ba18f0f7c2c47937c8303738cbd10322729ae Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:52:18 +0530 Subject: [PATCH 113/547] refactor: remove unused blockController import from builderBlockCopyPaste --- frontend/src/utils/builderBlockCopyPaste.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/utils/builderBlockCopyPaste.ts b/frontend/src/utils/builderBlockCopyPaste.ts index ecbe16d47..1a85bbb4c 100644 --- a/frontend/src/utils/builderBlockCopyPaste.ts +++ b/frontend/src/utils/builderBlockCopyPaste.ts @@ -19,7 +19,6 @@ import { nextTick } from "vue"; import { toast } from "vue-sonner"; import { webPages } from "../data/webPage"; import { BuilderClientScript } from "../types/Builder/BuilderClientScript"; -import blockController from "./blockController"; type BuilderPageClientScriptRef = { builder_script: string; idx?: number }; From 263201656b81d0fd4a0fc252c973df03e94e30cd Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:53:43 +0530 Subject: [PATCH 114/547] refactor: remove unused blockController import from useCanvasUtils --- frontend/src/utils/useCanvasUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/utils/useCanvasUtils.ts b/frontend/src/utils/useCanvasUtils.ts index 8e4086628..6e9f7f932 100644 --- a/frontend/src/utils/useCanvasUtils.ts +++ b/frontend/src/utils/useCanvasUtils.ts @@ -6,7 +6,6 @@ import { useCanvasHistory } from "@/utils/useCanvasHistory"; import { useElementBounding } from "@vueuse/core"; import { nextTick, reactive, ref, Ref } from "vue"; import { toast } from "vue-sonner"; -import blockController from "./blockController"; const canvasStore = useCanvasStore(); From 316376cb0bbc0d65570a1514a920cf85110563bd Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 00:59:02 +0530 Subject: [PATCH 115/547] refactor: fix typo --- frontend/src/utils/helpers.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 4d828bf5f..ee116b124 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -898,7 +898,7 @@ function triggerCopyEvent() { document.execCommand("copy"); } -const getValueForInheritedProp = (propName: string, block: Block, getDatScriptValue: (path: string) => any): any => { +const getValueForInheritedProp = (propName: string, block: Block, getDataScriptValue: (path: string) => any): any => { let parent = block.getParentBlock(); while (parent) { const parentProps = parent.getBlockProps(); @@ -906,7 +906,7 @@ const getValueForInheritedProp = (propName: string, block: Block, getDatScriptVa if (matchingProp) { if (matchingProp.type !== "inherited") { if (matchingProp.type === "dynamic" && matchingProp.value) { - return getDatScriptValue(matchingProp.value); + return getDataScriptValue(matchingProp.value); } else { if (matchingProp.isStandard && matchingProp.standardOptions) { if (matchingProp.standardOptions.type !== "string" && matchingProp.standardOptions.type !== "select") { @@ -924,7 +924,7 @@ const getValueForInheritedProp = (propName: string, block: Block, getDatScriptVa return matchingProp.value; } } else { - return getValueForInheritedProp(propName, parent, getDatScriptValue); + return getValueForInheritedProp(propName, parent, getDataScriptValue); } } parent = parent.getParentBlock(); @@ -935,7 +935,7 @@ const getValueForInheritedProp = (propName: string, block: Block, getDatScriptVa const getPropValue = ( propName: string, block: Block, - getDatScriptValue: (path: string) => any, + getDataScriptValue: (path: string) => any, defaultProps?: Record | null, ): any => { if (defaultProps && defaultProps[propName] !== undefined) { @@ -945,10 +945,10 @@ const getPropValue = ( if (blockProps[propName]) { const prop = blockProps[propName]; if (prop.type === "dynamic" && prop.value) { - return getDatScriptValue(prop.value); + return getDataScriptValue(prop.value); } else if (prop.type === "inherited") { if (prop.value) { - return getValueForInheritedProp(prop.value, block, getDatScriptValue); + return getValueForInheritedProp(prop.value, block, getDataScriptValue); } } else { if (prop.isStandard && prop.standardOptions) { From 2eb70e1a9607bd70800a72e52b83dfd1859e8fbc Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 01:12:39 +0530 Subject: [PATCH 116/547] fix: include comp. in comp. case (fragment mode) --- frontend/src/utils/blockController.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/utils/blockController.ts b/frontend/src/utils/blockController.ts index 3da19ab33..a16c3aee9 100644 --- a/frontend/src/utils/blockController.ts +++ b/frontend/src/utils/blockController.ts @@ -307,18 +307,16 @@ const blockController = { } const editingMode = canvasStore.editingMode; + while (block && block.isExtendedFromComponent()) { + if (block.extendedFromComponent) break; + block = block.getParentBlock()!; + } if (editingMode == "fragment") { - while (block && block.getParentBlock()) { - block = block.getParentBlock()!; - } - return block; - } else { - while (block && block.isExtendedFromComponent()) { - if (block.extendedFromComponent) break; + while (block && !block.isExtendedFromComponent() && block.getParentBlock()) { block = block.getParentBlock()!; } - return block; } + return block; }, getBlockScript: () => { return blockController.getSelectedBlocks()[0]?.getBlockScript(); // TODO: change to first selected block From 4b38ce0eb46418cb68e4eb96deea791ac2484c52 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 01:14:51 +0530 Subject: [PATCH 117/547] refactor: replace getSelectedBlocks with getFirstSelectedBlock --- frontend/src/utils/blockController.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/blockController.ts b/frontend/src/utils/blockController.ts index a16c3aee9..417f67d19 100644 --- a/frontend/src/utils/blockController.ts +++ b/frontend/src/utils/blockController.ts @@ -319,13 +319,13 @@ const blockController = { return block; }, getBlockScript: () => { - return blockController.getSelectedBlocks()[0]?.getBlockScript(); // TODO: change to first selected block + return blockController.getFirstSelectedBlock()?.getBlockScript(); }, setBlockScript: (script: string) => { - blockController.getSelectedBlocks()[0]?.setBlockScript(script); + blockController.getFirstSelectedBlock()?.setBlockScript(script); }, getBlockProps: () => { - return blockController.getSelectedBlocks()[0]?.getBlockProps(); + return blockController.getFirstSelectedBlock()?.getBlockProps(); }, setBlockProp: (key: string, value: string) => { const allProps = blockController.getBlockProps(); From 467ecdad0c2e821f31e5af5bd4de92ae39c186db Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Sun, 7 Dec 2025 09:25:57 +0530 Subject: [PATCH 118/547] refactor: update Popover margin and width properties --- frontend/src/components/ArrayInput.vue | 2 +- frontend/src/components/ObjectInput.vue | 2 +- frontend/src/components/PropsEditor.vue | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ArrayInput.vue b/frontend/src/components/ArrayInput.vue index b45f5238b..f397f0307 100644 --- a/frontend/src/components/ArrayInput.vue +++ b/frontend/src/components/ArrayInput.vue @@ -1,5 +1,5 @@