diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index a4746b9..99efecc 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,2 +1,3 @@
# Mass reformats (style: PRs), one squash SHA per line with its subject as a comment.
# GitHub reads this file; locally the dev shell sets blame.ignoreRevsFile.
+0f2bacb5c3f826e683a21223e4adf81ad0eba101 # style: adopt the app standards (managed config and formatting) (#7)
diff --git a/.oxlintrc.json b/.oxlintrc.json
index e5b3a95..44239a6 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -54,15 +54,7 @@
}
},
{
- "files": [
- "esign/public/js/controls/font_select.js",
- "esign/public/js/controls/signature.js",
- "esign/public/js/controls/upload.js",
- "esign/public/js/controls/utils.js",
- "esign/public/js/fonts.js",
- "esign/public/js/web/esign_webform_script.js",
- "esign/public/js/web_form.js"
- ],
+ "files": ["esign/public/js/web_form.js"],
"globals": {
"frappe": "readonly",
"__": "readonly",
diff --git a/esign/esign/__init__.py b/esign/esign/__init__.py
index 09703f5..3b4c860 100644
--- a/esign/esign/__init__.py
+++ b/esign/esign/__init__.py
@@ -5,14 +5,23 @@
import json
import frappe
+
+# Imported as a module and looked up at call time, so a patched get_attachments (cloud_storage
+# replaces it) is the one called.
+import frappe.desk.form.load
from frappe import _
from frappe.core.doctype.communication.communication import Communication
-from frappe.utils import now
+from frappe.utils import escape_html, now
# Communication type for eSign audit trail
ESIGN_COMMUNICATION_TYPE = "eSign"
+def _escape(value: object) -> str | None:
+ """A stored audit value, HTML-escaped for the timeline card (None stays None)."""
+ return None if value is None else escape_html(str(value))
+
+
def get_timeline_content(doctype: str, docname: str) -> list[dict]:
"""Return eSign audit trail entries for the document timeline.
@@ -48,7 +57,7 @@ def get_timeline_content(doctype: str, docname: str) -> list[dict]:
# Get the attached signed PDF file URL using the patched get_attachments
# This works for both standard Frappe and cloud_storage setups
file_url = None
- attachments = frappe.desk.form.load.get_attachments("Communication", comm.name) # type: ignore
+ attachments = frappe.desk.form.load.get_attachments("Communication", comm.name)
for attachment in attachments:
if attachment.get("file_name", "").endswith(".pdf"):
file_url = attachment.get("file_url")
@@ -60,18 +69,21 @@ def get_timeline_content(doctype: str, docname: str) -> list[dict]:
"is_card": True,
"creation": comm.communication_date or comm.creation,
"template": "esign_timeline",
+ # The timeline template prints these unescaped ({{ }} is raw in frappe's
+ # microtemplates), and the signer controls several of them (user agent, email
+ # parameter, form fields), so every value is escaped here.
"template_data": {
"creation": str(comm.creation),
- "file_url": file_url,
- "signer_name": audit_data.get("signer_name") or comm.sender_full_name,
- "signer_email": audit_data.get("signer_email") or comm.sender,
- "timestamp": audit_data.get("timestamp"),
- "ip_address": audit_data.get("ip_address"),
- "user_agent": audit_data.get("user_agent"),
- "web_form": audit_data.get("web_form"),
- "print_format": audit_data.get("print_format"),
- "signed_fields": audit_data.get("signed_fields", []),
- "pdf_hash": audit_data.get("pdf_hash"),
+ "file_url": _escape(file_url),
+ "signer_name": _escape(audit_data.get("signer_name") or comm.sender_full_name),
+ "signer_email": _escape(audit_data.get("signer_email") or comm.sender),
+ "timestamp": _escape(audit_data.get("timestamp")),
+ "ip_address": _escape(audit_data.get("ip_address")),
+ "user_agent": _escape(audit_data.get("user_agent")),
+ "web_form": _escape(audit_data.get("web_form")),
+ "print_format": _escape(audit_data.get("print_format")),
+ "signed_fields": [_escape(field) for field in audit_data.get("signed_fields") or []],
+ "pdf_hash": _escape(audit_data.get("pdf_hash")),
},
}
)
@@ -126,6 +138,10 @@ def send_esign_email(
except frappe.DoesNotExistError:
frappe.throw(_("Document {0} {1} not found").format(doctype, name))
+ # The email carries a share key that opens the document to its recipients, so the sender
+ # needs the same permission frappe's own email dialog asks for.
+ frappe.has_permission(doctype, ptype="email", doc=name, throw=True)
+
# Validate web form
from esign.esign.custom.web_form import EsignWebForm, get_esign_link
diff --git a/esign/esign/custom/web_form.py b/esign/esign/custom/web_form.py
index 2a459fc..9cf653b 100644
--- a/esign/esign/custom/web_form.py
+++ b/esign/esign/custom/web_form.py
@@ -4,8 +4,10 @@
import hashlib
import json
import uuid
+from collections.abc import Generator
+from contextlib import contextmanager
from datetime import datetime
-from typing import TYPE_CHECKING, TypedDict, cast
+from typing import TypedDict, cast
import frappe
from frappe import _
@@ -19,7 +21,7 @@
from frappe.rate_limiter import rate_limit
from frappe.twofactor import get_qr_svg_code
from frappe.types import DF
-from frappe.utils import cint, get_frappe_version, now
+from frappe.utils import cint, escape_html, get_frappe_version, now, validate_email_address
from frappe.website.doctype.web_form.web_form import WebForm as BaseWebForm
from frappe.www.printview import validate_print_permission
@@ -62,34 +64,6 @@ def validate_esign_key(key: str, doc: Document, require_key: bool = False) -> bo
return True
-FRAPPE_MAJOR = int(frappe.__version__.split(".")[0])
-
-# Choose the base class for EsignWebForm.
-#
-# Frappe v16+ registers this class via `extend_doctype_class` (see hooks.py) and composes
-# the final controller as `type(..., (*extensions_in_reverse_install_order, WebForm))`.
-# The payments app registers its PaymentWebForm the same way, so its behaviour is still
-# mixed into the final class. If we also inherited from PaymentWebForm here, installing
-# esign before payments would produce bases (PaymentWebForm, EsignWebForm, WebForm),
-# which has no consistent MRO and breaks loading every Web Form. So on v16+ we only
-# subclass the stock WebForm.
-#
-# Frappe v15 uses `override_doctype_class`, where the last app wins; there we keep
-# inheriting from PaymentWebForm (when installed) so payment behaviour isn't lost.
-if TYPE_CHECKING or FRAPPE_MAJOR >= 16:
- _EsignWebFormBase = BaseWebForm
-else:
- try:
- from payments.overrides.payment_webform import PaymentWebForm # type: ignore
- except ImportError:
- # Dummy mixin when payments isn't installed
- class PaymentWebForm:
- pass
-
- class _EsignWebFormBase(PaymentWebForm, BaseWebForm):
- pass
-
-
class RequestData(TypedDict):
host_url: str | None
scheme: str
@@ -132,6 +106,16 @@ def extract_param_from_referrer(param: str = "") -> str:
return ""
+def _fieldname(row: Document) -> str | None:
+ """A web form row's fieldname.
+
+ Frappe declares WebFormField.fieldname (and Notification.set_property_after_alert) as
+ `Literal[None]`, its type for a Select whose options are only filled in at runtime, so
+ the value is read through `get`, which is untyped.
+ """
+ return row.get("fieldname")
+
+
def _find_email_fieldname(wf: EsignWebForm) -> str | None:
"""Find the fieldname of a Data field with Options='Email' on the web form.
@@ -142,20 +126,28 @@ def _find_email_fieldname(wf: EsignWebForm) -> str | None:
"""
meta = frappe.get_meta(wf.doc_type)
for field in wf.web_form_fields:
- if field.fieldtype == "Data" and field.fieldname:
- df = meta.get_field(field.fieldname)
+ fieldname = _fieldname(field)
+ if field.fieldtype == "Data" and fieldname:
+ df = meta.get_field(fieldname)
if df and df.fieldtype == "Data" and (df.options or "").strip().lower() == "email":
- return field.fieldname
+ return fieldname
return None
-class EsignWebForm(_EsignWebFormBase):
- """Custom WebForm override for eSign-specific functionality with custom field types"""
+class EsignWebForm(BaseWebForm):
+ """eSign behaviour for Web Form, registered through `extend_doctype_class`.
+
+ Frappe composes the final controller from the stock WebForm and every installed app's
+ extension (payments registers one too), so this subclasses the stock WebForm only:
+ subclassing another app's extension would break the class hierarchy whenever that app is
+ installed after esign.
+ """
# Custom fields from ../custom/web_form.json
esign_enabled: DF.Check | None
esign_force_mobile: DF.Check | None
- esign_update_field: DF.Select[""] | None # noqa: F722 -- options are set per doctype at runtime, as frappe generates them
+ # A Select whose options are the target doctype's fields, set at runtime (web_form.js).
+ esign_update_field: DF.Data | None
esign_update_value: DF.Data | None
esign_submit_on_response: DF.Check | None
esign_send_notification: DF.Check | None
@@ -215,7 +207,7 @@ def _get_signed_pdf(self, doc: Document) -> dict | None:
def _check_signature_exists(self, doc: Document) -> bool:
"""Check if all signature fields in the web form already have values."""
signature_fields = [
- field.fieldname for field in self.web_form_fields if field.fieldtype == "Signature"
+ _fieldname(field) for field in self.web_form_fields if field.fieldtype == "Signature"
]
# If no signature fields, not applicable
@@ -240,10 +232,15 @@ def get_context(self, context):
# Get document without permission check - we'll validate via share key
doc = frappe.get_doc(self.doc_type, cur_doc_name)
- doc.flags.ignore_permissions = True
- # Validate access via share key or fallback to standard permissions
- validate_esign_key(key, doc)
+ # Validate access via share key or fallback to standard permissions. The document is
+ # only marked permission-free afterwards: frappe's website permission check (part of
+ # the fallback) passes any document that already carries ignore_permissions. Without a
+ # key, the web form's own rules (owner, website permission, document permissions) also
+ # let a user in, as in frappe's web form and in accept; the preview and the signed-PDF
+ # download apply the same rule through the web form name the page passes them.
+ validate_esign_access(key, doc, self)
+ doc.flags.ignore_permissions = True
# Allow public access to the web form if validation passes
print_format = frappe.form_dict.get("format", self.print_format) or "standard"
@@ -265,7 +262,9 @@ def get_context(self, context):
context.doctype = self.doc_type
context.name = cur_doc_name
context.print_format = print_format
- form_fields: set[str] = {field.fieldname for field in self.web_form_fields if field.fieldname}
+ form_fields: set[str] = {
+ fieldname for field in self.web_form_fields if (fieldname := _fieldname(field))
+ }
form_fields.add("name")
form_fields.add("doctype")
context.reference_doc = {k: v for k, v in doc.as_dict().items() if k in form_fields}
@@ -280,6 +279,7 @@ def get_context(self, context):
super().add_custom_context_and_script(context)
context.key = key # Pass key to frontend for async API calls
+ context.esign_web_form = self.name # Lets the async calls apply the keyless web form rules
context.printview_url = (
"/api/method/frappe.utils.print_format.download_pdf?"
f"doctype={self.doc_type}&name={cur_doc_name}&format={print_format}&key={key}"
@@ -289,7 +289,7 @@ def get_context(self, context):
[
"esign.control.bundle.css",
"esign.web.bundle.css",
- "/assets/esign/dist/esign-fonts.css",
+ "esign-fonts.bundle.css",
]
)
context.web_include_js.extend(
@@ -312,7 +312,14 @@ def get_context(self, context):
# Build the download URL through our key-validated endpoint
from urllib.parse import urlencode
- params = urlencode({"doctype": self.doc_type, "docname": cur_doc_name, "key": key})
+ params = urlencode(
+ {
+ "doctype": self.doc_type,
+ "docname": cur_doc_name,
+ "key": key,
+ "web_form": self.name,
+ }
+ )
context.signed_pdf_url = (
f"/api/method/esign.esign.custom.web_form.download_signed_pdf?{params}"
)
@@ -342,7 +349,6 @@ def has_web_form_permission(self, doctype, name, ptype="read"):
if key:
try:
doc = frappe.get_doc(doctype, name)
- doc.flags.ignore_permissions = True
validate_esign_key(key, doc, require_key=True)
return True
except frappe.PermissionError:
@@ -353,6 +359,42 @@ def has_web_form_permission(self, doctype, name, ptype="read"):
return super().has_web_form_permission(doctype, name, ptype)
+def _get_esign_web_form(web_form: EsignWebForm | str, doctype: str) -> EsignWebForm | None:
+ """The eSign-enabled web form for doctype, or None if web_form is not one."""
+ if isinstance(web_form, str):
+ if not frappe.db.exists("Web Form", web_form):
+ return None
+ web_form = cast(EsignWebForm, frappe.get_cached_doc("Web Form", web_form))
+ if not web_form.get("esign_enabled") or web_form.get("doc_type") != doctype:
+ return None
+ return web_form
+
+
+def validate_esign_access(
+ key: str, doc: Document, web_form: EsignWebForm | str | None = None, require_key: bool = False
+) -> bool:
+ """Validate access to an eSign document for the signing page and its endpoints.
+
+ A key is checked as the share key. Without one, a logged-in user the eSign web form's own
+ rules admit (owner, website permission, document permissions) is let in, so the page, its
+ preview and its signed-PDF download agree; anyone else falls back to validate_esign_key.
+
+ Args:
+ key: The share key, or an empty string
+ doc: The document being accessed
+ web_form: The eSign web form (or its name) the request comes from, if any
+ require_key: Passed on to validate_esign_key when the web form rules do not admit
+
+ Raises:
+ frappe.PermissionError: If access is not granted
+ """
+ if not key and web_form and frappe.session.user != "Guest":
+ wf = _get_esign_web_form(web_form, doc.doctype)
+ if wf and BaseWebForm.has_web_form_permission(wf, doc.doctype, str(doc.name), "read"):
+ return True
+ return validate_esign_key(key, doc, require_key=require_key)
+
+
_UNSET = object()
@@ -399,9 +441,9 @@ def _send_esign_notification(
)
return
- # Build context similar to how Notification.send() does it
+ # Build context the way Notification.send() does
context = get_context(doc)
- context = {"doc": doc, "alert": notification, "comments": None}
+ context.update({"alert": notification, "comments": None})
if doc.get("_comments"):
context["comments"] = json.loads(str(doc.get("_comments", "[]")))
@@ -416,12 +458,14 @@ def _send_esign_notification(
)
return
- # Render subject and message
+ # Render subject and message. Both come from the Notification, which only System
+ # Managers author: the same trusted source frappe's own Notification.send_an_email
+ # renders them from, with the same sandboxed environment.
subject = notification.subject
if subject and "{" in subject:
- subject = frappe.render_template(notification.subject, context)
+ subject = frappe.render_template(subject, context) # nosemgrep: frappe-ssti
- message = frappe.render_template(notification.message, context) or ""
+ message = frappe.render_template(notification.message, context) or "" # nosemgrep: frappe-ssti
# Append audit trail information to the notification email
if audit_data:
@@ -442,9 +486,11 @@ def _send_esign_notification(
audit_rows.append((_("PDF SHA-256"), pdf_hash))
if audit_rows:
+ # The values come from the signer's request (user agent, email parameter), so
+ # they are escaped before going into the HTML body.
table_rows = "".join(
f'
{label}
'
- f'
{value}
'
+ f'
{escape_html(str(value))}
'
for label, value in audit_rows
)
message += (
@@ -503,14 +549,14 @@ def _send_esign_notification(
)
# Handle set_property_after_alert if configured
- if notification.set_property_after_alert:
+ fieldname = notification.get("set_property_after_alert") # Literal[None] in frappe's types
+ if fieldname:
allow_update = True
- field_df = doc.meta.get_field(notification.set_property_after_alert)
+ field_df = doc.meta.get_field(fieldname)
if doc.docstatus.is_submitted() and field_df and not field_df.allow_on_submit:
allow_update = False
try:
if allow_update and not doc.flags.in_notification_update:
- fieldname = notification.set_property_after_alert
value: str | int | float | None = notification.property_value
if field_df and field_df.fieldtype in numeric_fieldtypes:
value = cint(value or 0)
@@ -535,21 +581,14 @@ def _send_esign_notification(
)
-def attach_print_to_document(
- doc: Document,
- print_format: str,
- request_data: RequestData,
- audit_data: AuditData,
- notification_name: str | None = None,
-):
- """Background job to attach the signed PDF to the document.
+@contextmanager
+def _job_request(request_data: RequestData) -> Generator[None]:
+ """Give a background job the signing request's host, scheme and headers.
- Also records audit trail and SHA-256 hash for document integrity verification.
- Creates a Communication record for timeline display via additional_timeline_content hook.
- Optionally triggers a notification with the validated signed PDF attached.
+ The PDF renderer and URL helpers read them from `frappe.local.request`. Only the
+ context-local request is set: assigning `frappe.request` would replace the module-level
+ LocalProxy for the rest of the worker process and leak into later jobs.
"""
- from frappe import attach_print
-
mock_request = frappe._dict(
{
"headers": request_data.get("headers", {}),
@@ -560,72 +599,55 @@ def attach_print_to_document(
"scheme": request_data.get("scheme", "https"),
}
)
-
- # Only set the context-local request. Assigning `frappe.request` would replace the
- # module-level LocalProxy for the rest of this worker process and leak into later jobs.
prev_request = getattr(frappe.local, "request", _UNSET)
frappe.local.request = mock_request
-
try:
- # Refetch the document to ensure latest data
- doc.reload()
+ yield
+ finally:
+ if prev_request is _UNSET:
+ if hasattr(frappe.local, "request"):
+ del frappe.local.request
+ else:
+ frappe.local.request = prev_request
- # Generate the PDF
- timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
- print_data = attach_print(
- doctype=doc.doctype,
- name=doc.name,
- file_name=f"{doc.name}_signed_{timestamp_str}",
- print_format=print_format,
- doc=doc,
- print_letterhead=False,
- )
- # Compute SHA-256 hash of the PDF content
- pdf_content = print_data["fcontent"]
- pdf_hash = hashlib.sha256(pdf_content).hexdigest()
- frappe_content_hash = get_content_hash(pdf_content)
+def _read_file_bytes(file_doc: File) -> bytes:
+ """The stored bytes of a local File. File.get_content decodes anything that decodes as
+ text (a PDF often does, as windows-1252), and writing that back would change the bytes."""
+ with open(file_doc.get_full_path(), "rb") as f:
+ return f.read()
- # Attach the signed PDF to the document
- # Note: content_hash must be set explicitly for cloud_storage deduplication to work,
- # as cloud_storage's validate() doesn't call the core File validate which generates it
- file_doc = cast(
- File,
- frappe.get_doc(
- {
- "doctype": "File",
- "file_name": print_data["fname"],
- "attached_to_doctype": doc.doctype,
- "attached_to_name": doc.name,
- "folder": "Home/Attachments",
- "is_private": True,
- "content": pdf_content,
- "content_hash": frappe_content_hash,
- }
- ),
- )
- file_doc.insert(ignore_permissions=True)
- # Check if this is a cloud_storage-enhanced File (has s3_key and file_association attributes)
- is_cloud_storage = hasattr(file_doc, "s3_key") and hasattr(file_doc, "file_association")
+def _is_cloud_storage_file(file_doc: File) -> bool:
+ """Whether cloud_storage's File extension (s3_key, file_association) is installed."""
+ return hasattr(file_doc, "s3_key") and hasattr(file_doc, "file_association")
- if is_cloud_storage:
- # Extract s3_key from the file_url and persist it
- s3_key = str(file_doc.file_url or "").replace("/api/method/retrieve?key=", "")
- if s3_key:
- file_doc.db_set("s3_key", s3_key)
- # Commit the file transaction to avoid deadlock with Communication's _comments update.
- # Cloud storage's associate_files() may have modified the parent document, and the
- # Communication insert will try to update _comments on the same document.
- frappe.db.commit()
+def record_signature(
+ doctype: str,
+ name: str,
+ file_name: str,
+ pdf_hash: str,
+ request_data: RequestData,
+ audit_data: AuditData,
+ notification_name: str | None = None,
+):
+ """Background job: record the signing on the document's timeline.
+
+ Creates the eSign Communication (audit data as JSON, shown through the
+ additional_timeline_content hook), links the signed PDF to it and, when the web form
+ names one, sends the notification with that PDF attached.
+ """
+ with _job_request(request_data):
+ doc = frappe.get_doc(doctype, name)
+ file_doc = cast(File, frappe.get_doc("File", file_name))
# Create Communication record for timeline display with audit data as JSON
audit_content = json.dumps(
{
**audit_data,
"pdf_hash": pdf_hash,
- "file_name": print_data["fname"],
+ "file_name": file_doc.file_name,
}
)
@@ -646,11 +668,10 @@ def attach_print_to_document(
comm.insert(ignore_permissions=True)
# Associate the PDF with the Communication
- if is_cloud_storage:
+ if _is_cloud_storage_file(file_doc):
# For cloud_storage, add a file_association instead of duplicating the file
from frappe.utils import get_datetime
- file_doc.reload()
file_doc.append(
"file_association",
{
@@ -668,13 +689,13 @@ def attach_print_to_document(
frappe.get_doc(
{
"doctype": "File",
- "file_name": print_data["fname"],
+ "file_name": file_doc.file_name,
"attached_to_doctype": "Communication",
"attached_to_name": comm.name,
"folder": "Home/Attachments",
"is_private": True,
- "content": pdf_content,
- "content_hash": frappe_content_hash,
+ "content": _read_file_bytes(file_doc),
+ "content_hash": file_doc.content_hash,
}
),
)
@@ -683,17 +704,88 @@ def attach_print_to_document(
# Trigger notification with the validated signed PDF attached
if notification_name:
_send_esign_notification(notification_name, doc, file_doc, audit_data, pdf_hash)
- finally:
- if prev_request is _UNSET:
- if hasattr(frappe.local, "request"):
- del frappe.local.request
- else:
- frappe.local.request = prev_request
+
+
+def attach_print_to_document(
+ doc: Document,
+ print_format: str,
+ request_data: RequestData,
+ audit_data: AuditData,
+ notification_name: str | None = None,
+):
+ """Background job: render the signed PDF and attach it to the document.
+
+ The audit trail (the Communication, its copy of the PDF and the notification) is written
+ by `record_signature`, a second job queued to start once this job's transaction has
+ committed. The File has to be committed before the Communication updates the document's
+ `_comments`: cloud storage's file association may have changed the document too, and
+ doing both in one transaction deadlocked.
+ """
+ from frappe import attach_print
+
+ with _job_request(request_data):
+ # Refetch the document to ensure latest data
+ doc.reload()
+
+ # Generate the PDF
+ timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
+ print_data = attach_print(
+ doctype=doc.doctype,
+ name=doc.name,
+ file_name=f"{doc.name}_signed_{timestamp_str}",
+ print_format=print_format,
+ doc=doc,
+ print_letterhead=False,
+ )
+
+ # Compute SHA-256 hash of the PDF content
+ pdf_content = print_data["fcontent"]
+ pdf_hash = hashlib.sha256(pdf_content).hexdigest()
+
+ # Attach the signed PDF to the document
+ # Note: content_hash must be set explicitly for cloud_storage deduplication to work,
+ # as cloud_storage's validate() doesn't call the core File validate which generates it
+ file_doc = cast(
+ File,
+ frappe.get_doc(
+ {
+ "doctype": "File",
+ "file_name": print_data["fname"],
+ "attached_to_doctype": doc.doctype,
+ "attached_to_name": doc.name,
+ "folder": "Home/Attachments",
+ "is_private": True,
+ "content": pdf_content,
+ "content_hash": get_content_hash(pdf_content),
+ }
+ ),
+ )
+ file_doc.insert(ignore_permissions=True)
+
+ if _is_cloud_storage_file(file_doc):
+ # Extract s3_key from the file_url and persist it
+ s3_key = str(file_doc.file_url or "").replace("/api/method/retrieve?key=", "")
+ if s3_key:
+ file_doc.db_set("s3_key", s3_key)
+
+ frappe.enqueue(
+ record_signature,
+ queue="short",
+ timeout=300,
+ enqueue_after_commit=True,
+ doctype=doc.doctype,
+ name=doc.name,
+ file_name=file_doc.name,
+ pdf_hash=pdf_hash,
+ request_data=request_data,
+ audit_data=audit_data,
+ notification_name=notification_name,
+ )
@frappe.whitelist()
def get_esign_link(
- doc: Document, web_form_name: str, print_format_name: str = "", recipient_email: str = ""
+ doc: Document | str | dict, web_form_name: str, print_format_name: str = "", recipient_email: str = ""
) -> str:
"""Get the eSign Web Form link for a given document and web form name.
@@ -716,6 +808,10 @@ def get_esign_link(
doc = frappe.get_doc(doc["doctype"], doc["name"])
except Exception as e:
return str(e)
+ # A reference from a request: the link carries a share key that opens the document to
+ # whoever holds it, so the caller must be allowed to email the document (as for
+ # send_esign_email). Server-side callers (Jinja, Python) pass the Document itself.
+ frappe.has_permission(doc.doctype, ptype="email", doc=doc, throw=True)
try:
web_form = EsignWebForm("Web Form", web_form_name)
@@ -725,9 +821,9 @@ def get_esign_link(
# Generate the URL
from frappe.utils import get_url
- base_url = get_url()
+ base_url = get_url().rstrip("/")
share_key = doc.get_document_share_key()
- web_form_path = f"{web_form.route}/{doc.name}/edit"
+ web_form_path = f"/{(web_form.route or '').lstrip('/')}/{doc.name}/edit"
query_string = f"?key={share_key}"
if print_format_name:
query_string += f"&format={print_format_name}"
@@ -749,7 +845,7 @@ def get_esign_link(
@frappe.whitelist(allow_guest=True)
@rate_limit(key="web_form", limit=30, seconds=60)
def get_print_html(
- doctype: str, docname: str, print_format: str = "standard", key: str = ""
+ doctype: str, docname: str, print_format: str = "standard", key: str = "", web_form: str = ""
) -> PrintHtmlResponse:
"""Get the print HTML and styles for a document.
@@ -760,6 +856,9 @@ def get_print_html(
doctype: The DocType of the document
docname: The name of the document
print_format: The print format to use (default: "standard")
+ key: The document share key, if any
+ web_form: The eSign web form the page belongs to; without a key, its rules can admit
+ the user (see validate_esign_access)
Returns:
PrintHtmlResponse with print_html and print_style
@@ -777,15 +876,18 @@ def get_print_html(
# Get the document without permission checks - we'll validate via share key
doc = frappe.get_doc(doctype, docname)
+
+ # Validate access via share key or fallback to standard permissions. This has to run
+ # before the document or the request is marked permission-free: without a key, the
+ # fallback is the standard print permission check, which ignore_print_permissions turns
+ # off and which passes a document that carries ignore_permissions.
+ validate_esign_access(key, doc, web_form)
doc.flags.ignore_permissions = True
- # Skip internal permission checks since we're using key-based auth
- # This prevents msgprint warnings from has_permission checks
+ # Access is granted: skip the renderer's own permission checks, which would only add
+ # msgprint warnings for a Guest holding a valid key.
frappe.flags.ignore_print_permissions = True
- # Validate access via share key or fallback to standard permissions
- validate_esign_key(key, doc)
-
# Get print format document
meta = frappe.get_meta(doctype)
print_format_doc = get_print_format_doc(print_format, meta=meta)
@@ -794,7 +896,7 @@ def get_print_html(
# Get rendered print HTML
print_html = get_rendered_template(
doc=doc,
- print_format=print_format_doc, # type: ignore
+ print_format=print_format_doc,
meta=meta,
trigger_print=False,
no_letterhead=bool(frappe.form_dict.get("no_letterhead")),
@@ -805,7 +907,7 @@ def get_print_html(
# Get print styles
print_style = get_print_style(
style=frappe.form_dict.get("style"),
- print_format=print_format_doc, # type: ignore
+ print_format=print_format_doc,
)
# Clean up any local message log
@@ -819,7 +921,7 @@ def get_print_html(
@frappe.whitelist(allow_guest=True)
@rate_limit(key="esign_download", limit=20, seconds=60)
-def download_signed_pdf(doctype: str, docname: str, key: str = ""):
+def download_signed_pdf(doctype: str, docname: str, key: str = "", web_form: str = ""):
"""Download a signed PDF attachment with key-based access validation.
This endpoint allows Guest users to download private signed PDF files
@@ -829,6 +931,8 @@ def download_signed_pdf(doctype: str, docname: str, key: str = ""):
doctype: The DocType of the document
docname: The name of the document
key: The document share key for access validation
+ web_form: The eSign web form the page belongs to; without a key, its rules can admit
+ a logged-in user (see validate_esign_access)
Returns:
The PDF file response
@@ -838,10 +942,11 @@ def download_signed_pdf(doctype: str, docname: str, key: str = ""):
# Get the document without permission checks - we'll validate via share key
doc = frappe.get_doc(doctype, docname)
- doc.flags.ignore_permissions = True
- # Validate access via share key - require key for guest access
- validate_esign_key(key, doc, require_key=(frappe.session.user == "Guest"))
+ # Validate access via share key - require key for guest access. Without a key a user
+ # needs the web form's permission or print permission, which a document marked
+ # ignore_permissions would skip.
+ validate_esign_access(key, doc, web_form, require_key=(frappe.session.user == "Guest"))
# Find the signed PDF for this document
# First try to find an eSign Communication for this document
@@ -906,13 +1011,16 @@ def download_signed_pdf(doctype: str, docname: str, key: str = ""):
# there instead.
if file_url.startswith("/api/method/retrieve"):
full_file = frappe.get_doc("File", file_doc.get("name"))
- sharing_uuid = full_file.get("sharing_link")
- if not sharing_uuid:
- sharing_uuid = uuid.uuid4()
- full_file.db_set("sharing_link", str(int(sharing_uuid) >> 64))
- frappe.db.commit() # ensure sharing_link is saved before redirect
+ sharing_link = full_file.get("sharing_link")
+ if not sharing_link:
+ # The same key format cloud_storage's own get_sharing_link writes.
+ sharing_link = str(uuid.uuid4().int >> 64)
+ full_file.db_set("sharing_link", sharing_link)
+ # A GET request is rolled back at its end unless it asks to commit; the link must
+ # exist by the time the browser follows the redirect.
+ frappe.local.flags.commit = True
frappe.local.response["type"] = "redirect"
- frappe.local.response["location"] = f"/api/method/share?key={sharing_uuid}"
+ frappe.local.response["location"] = f"/api/method/share?key={sharing_link}"
return
# Handle private vs public files (local storage)
@@ -983,8 +1091,9 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A
signer_name = None
# 1. From the email URL search param (link was sent via email)
+ # The parameter is the signer's own input, so only a valid address is recorded.
email_param = extract_param_from_referrer("email")
- if email_param:
+ if email_param and validate_email_address(email_param) == email_param:
signer_email = email_param
# 2. From a Data(Email) field on the web form
@@ -1011,9 +1120,9 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A
signature_fieldtypes = ("Signature", "SignaturePad")
for field in wf.web_form_fields:
if field.fieldtype in signature_fieldtypes:
- value = doc.get(field.fieldname)
- if value:
- signed_fields.append(field.label or field.fieldname)
+ fieldname = _fieldname(field)
+ if fieldname and doc.get(fieldname):
+ signed_fields.append(field.label or fieldname)
return AuditData(
timestamp=now(),
@@ -1035,24 +1144,16 @@ def _enqueue_print_attachment(doc: Document, wf: EsignWebForm):
print_format = wf.print_format or "standard"
# Extract only serializable data from request
+ request = getattr(frappe.local, "request", None)
+ headers = getattr(request, "headers", None)
request_data: RequestData = {
- "host_url": None,
- "scheme": "https",
- "host": None,
- "headers": {},
- "method": None,
+ "host_url": getattr(request, "host_url", None),
+ "scheme": getattr(request, "scheme", "https"),
+ "host": getattr(request, "host", None),
+ "headers": dict(headers) if headers is not None else {},
+ "method": getattr(request, "method", None),
}
- for attr, default in request_data.items():
- if hasattr(frappe, "request") and hasattr(frappe.request, attr):
- value = getattr(frappe.request, attr)
- # Convert headers to dict if it's the headers attribute
- if attr == "headers" and value is not None:
- value = dict(value)
- request_data[attr] = value
- else:
- request_data[attr] = default
-
# Collect audit data
audit_data = _collect_audit_data(doc, wf, print_format)
@@ -1113,6 +1214,17 @@ def accept(web_form, data):
except frappe.PermissionError, frappe.exceptions.LinkExpired:
frappe.throw(_("Invalid or expired access key"), frappe.PermissionError)
+ # An update needs the document's share key, web form write permission or DocType write
+ # permission: the three ways frappe's own accept lets an update through (the last one by
+ # saving with permission checks on). Otherwise anyone who knows a document name could
+ # overwrite its form fields.
+ if (
+ data.name
+ and not wf.has_web_form_permission(doctype, data.name, "write")
+ and not frappe.has_permission(doctype, "write", data.name)
+ ):
+ frappe.throw(_("You are not permitted to update this document"), frappe.PermissionError)
+
# Now proceed with the form submission using ignore_permissions
files = []
files_to_delete = []
@@ -1142,12 +1254,12 @@ def accept(web_form, data):
# Set web form field values
for field in wf.web_form_fields:
- fieldname = field.fieldname
+ fieldname = _fieldname(field)
df = meta.get_field(fieldname)
value = data.get(fieldname, "")
if df and df.fieldtype in ("Attach", "Attach Image"):
- if value and "data:" and "base64" in value:
+ if value and "data:" in value and "base64" in value:
files.append((fieldname, value))
if not doc.name:
doc.set(fieldname, "")
diff --git a/esign/hooks.py b/esign/hooks.py
index 14dadf5..2018c5a 100644
--- a/esign/hooks.py
+++ b/esign/hooks.py
@@ -1,10 +1,6 @@
# Copyright (c) 2026, Avunu LLC and contributors
# For license information, please see license.txt
-import frappe
-
-frappe_version = int(frappe.__version__.split(".")[0])
-
app_name = "esign"
app_title = "eSign"
app_publisher = "Avunu LLC"
@@ -12,10 +8,13 @@
app_email = "mail@avu.nu"
app_license = "MIT"
+# The esbuild entries are .js files that import the TypeScript modules: `bench build` keys
+# assets.json by the entry's name and `bench build --using-cached` by the built file's, and only a
+# .js entry gets the same key from both.
app_include_js = ["esign.desk.bundle.js", "esign.control.bundle.js"]
app_include_css = [
"esign.control.bundle.css",
- "/assets/esign/dist/esign-fonts.css",
+ "esign-fonts.bundle.css",
]
# Install/migrate hooks for communication type registration
@@ -33,16 +32,12 @@
"Web Form": "public/js/web_form.js",
}
-if frappe_version >= 16:
- extend_doctype_class = {
- "Web Form": "esign.esign.custom.web_form.EsignWebForm",
- "Email Template": "esign.esign.custom.email_template.EsignEmailTemplate",
- }
-else:
- override_doctype_class = {
- "Web Form": "esign.esign.custom.web_form.EsignWebForm",
- "Email Template": "esign.esign.custom.email_template.EsignEmailTemplate",
- }
+# Frappe v16 composes these onto the stock controllers (and any other app's extensions),
+# so each class subclasses the stock controller only.
+extend_doctype_class = {
+ "Web Form": "esign.esign.custom.web_form.EsignWebForm",
+ "Email Template": "esign.esign.custom.email_template.EsignEmailTemplate",
+}
jinja = {
diff --git a/esign/public/css/esign-fonts.entry.css b/esign/public/css/esign-fonts.entry.css
new file mode 100644
index 0000000..4665192
--- /dev/null
+++ b/esign/public/css/esign-fonts.entry.css
@@ -0,0 +1,12 @@
+/* Copyright (c) 2026, Avunu LLC and contributors
+ For license information, please see license.txt */
+
+/*
+ * The signature fonts, bundled by Vite (vite.config.mjs) into one stylesheet with its font
+ * files. scripts/vite-register.mjs registers it as esign-fonts.bundle.css, the name hooks.py
+ * and the eSign web form use.
+ */
+@import "@fontsource/pacifico/index.css";
+@import "@fontsource/dancing-script/index.css";
+@import "@fontsource/great-vibes/index.css";
+@import "@fontsource/caveat/index.css";
diff --git a/esign/public/js/controls/base.ts b/esign/public/js/controls/base.ts
new file mode 100644
index 0000000..4f58444
--- /dev/null
+++ b/esign/public/js/controls/base.ts
@@ -0,0 +1,55 @@
+// Copyright (c) 2026, Avunu LLC and contributors
+// For license information, please see license.txt
+
+/**
+ * @fileoverview The frappe control class the eSign controls extend, typed for them.
+ *
+ * frappe-types declares every `frappe.ui.form.Control*` class as the base `Control`. At
+ * runtime `ControlData` extends `ControlInput` (frappe/public/js/frappe/form/controls/data.js),
+ * and the eSign controls use its input area and two of its own methods, so this module
+ * declares those members once and hands out the same class under that type.
+ *
+ * @author Avunu LLC
+ */
+
+import type { BaseControl, ControlInput, ControlOptions } from "frappe-types";
+
+/**
+ * A `ControlData` instance as the eSign controls use it. Its focus target (`$input`,
+ * `input`) is whatever element the control renders (a button, a canvas, a hidden input),
+ * which frappe only ever treats as an element.
+ */
+export interface ControlDataInstance extends Omit {
+ $input?: JQuery;
+ input?: HTMLElement;
+ // The methods the eSign controls override, kept as methods (Omit turns them into properties).
+ make_input(): void;
+ set_input(value: unknown): void;
+ get_input_value(): unknown;
+ get_value(): unknown;
+ refresh(): void;
+ refresh_input(): void;
+ /** frappe/public/js/frappe/form/controls/data.js: copies `df` attributes onto `$input`. */
+ set_input_attributes(): void;
+ /** frappe/public/js/frappe/form/controls/data.js: renders a value into the input. */
+ set_formatted_input(value: unknown): unknown;
+}
+
+type ControlMethod =
+ | "make_input"
+ | "set_input"
+ | "get_input_value"
+ | "get_value"
+ | "refresh"
+ | "refresh_input";
+
+export interface ControlDataClass {
+ new (opts: ControlOptions): ControlDataInstance;
+ prototype: ControlDataInstance;
+}
+
+/** `frappe.ui.form.ControlData`, typed as the class it is at runtime. */
+export const ControlData = frappe.ui.form.ControlData as ControlDataClass;
+
+/** Every eSign control is a frappe control: the type `frappe.ui.form` registers it under. */
+export type ControlClass = typeof BaseControl;
diff --git a/esign/public/js/controls/font_select.js b/esign/public/js/controls/font_select.ts
similarity index 69%
rename from esign/public/js/controls/font_select.js
rename to esign/public/js/controls/font_select.ts
index 04d7fa9..17b6404 100644
--- a/esign/public/js/controls/font_select.js
+++ b/esign/public/js/controls/font_select.ts
@@ -1,7 +1,8 @@
// Copyright (c) 2026, Avunu LLC and contributors
// For license information, please see license.txt
-import { toDom } from "./utils";
+import { ControlData } from "./base";
+import { toDom, type HastElement } from "./utils";
/**
* @fileoverview FontSelect control for Frappe Framework
@@ -14,12 +15,27 @@ import { toDom } from "./utils";
* @author Avunu LLC
*/
+/** A font option as the control shows it. */
+export interface FontOption {
+ value: string;
+ label: string;
+ fontFamily: string;
+}
+
+/** A font option as `df.options` gives it; `variants` are the family names to look for locally. */
+export interface FontOptionInput extends FontOption {
+ variants?: string[] | undefined;
+}
+
+/** One entry of `df.options`: a family name, or an object with any of the option's keys. */
+type RawFontOption = string | Partial;
+
/**
* Generates a unique anchor name for CSS anchor positioning
- * @returns {string} Unique anchor name
+ * @returns Unique anchor name
*/
let anchorCounter = 0;
-function generateAnchorName() {
+function generateAnchorName(): string {
return `--font-select-anchor-${++anchorCounter}`;
}
@@ -27,13 +43,13 @@ function generateAnchorName() {
* Checks which fonts from a list are available locally using the Local Font Access API.
* Falls back to returning all fonts if the API is unavailable or permission is denied.
*
- * @param {Array<{value: string, label: string, fontFamily: string, variants?: string[]}>} fonts
+ * @param fonts
* Array of font options. Each option can include:
* - value: The value to use when selected
* - label: Display label for the option
* - fontFamily: CSS font-family value
* - variants: Optional array of font family name variants to check for availability
- * @returns {Promise>}
+ * @returns
* Filtered array of available fonts
*
* @example
@@ -47,13 +63,14 @@ function generateAnchorName() {
* ];
* const available = await filterAvailableFonts(fonts);
*/
-export async function filterAvailableFonts(fonts) {
+export async function filterAvailableFonts(fonts: FontOptionInput[]): Promise {
if (!fonts || fonts.length === 0) {
return [];
}
// If Local Font Access API is not available, return all fonts
- if (!("queryLocalFonts" in window)) {
+ const queryLocalFonts = window.queryLocalFonts;
+ if (!queryLocalFonts) {
return fonts.map(({ value, label, fontFamily }) => ({
value,
label,
@@ -62,10 +79,10 @@ export async function filterAvailableFonts(fonts) {
}
try {
- const localFonts = await window.queryLocalFonts();
+ const localFonts = await queryLocalFonts.call(window);
const localFontNames = new Set(localFonts.map((f) => f.family.toLowerCase()));
- const available = [];
+ const available: FontOption[] = [];
for (const font of fonts) {
// Use variants if provided, otherwise use the label as the variant
const variants = font.variants || [font.label];
@@ -98,12 +115,27 @@ export async function filterAvailableFonts(fonts) {
}
}
-export class ControlFontSelect extends frappe.ui.form.ControlData {
+export class ControlFontSelect extends ControlData {
+ // Assigned in make_input and _build_ui, which frappe's control constructor reaches:
+ // `declare` keeps the class from re-initialising them afterwards.
+ declare _options: FontOptionInput[];
+ declare _available_options: FontOption[];
+ declare _is_hidden: boolean;
+ declare _init_promise: Promise | undefined;
+ declare _anchor_name: string;
+ declare _last_options_json: string | undefined;
+ declare font_select_wrapper: HTMLElement;
+ declare trigger_button: HTMLButtonElement;
+ declare value_display: HTMLSpanElement;
+ declare popover_element: HTMLDivElement;
+ declare options_container: HTMLDivElement;
+ declare hidden_input: HTMLInputElement;
+
/**
* Initializes the control. Called by Frappe's control lifecycle.
* Triggers async font availability check.
*/
- make_input() {
+ override make_input(): void {
if (this.has_input) return;
// Mark as having input to prevent re-entry
@@ -120,7 +152,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
* Async initialization - parses options, checks font availability, builds UI
* @private
*/
- async _init_async() {
+ async _init_async(): Promise {
// Parse options from df.options
this._options = this.parse_options();
@@ -134,7 +166,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
// If exactly one font, set it as the value
if (this._available_options.length === 1) {
- this.value = this._available_options[0].value;
+ this.value = this._available_options[0]?.value;
}
return;
}
@@ -143,7 +175,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
this._build_ui();
// Set initial value
- const defaultValue = this.df.default || (this._available_options[0] && this._available_options[0].value);
+ const defaultValue = this.df.default || this._available_options[0]?.value;
if (defaultValue) {
this.set_input(defaultValue);
}
@@ -153,7 +185,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
* Hides the control when there's no meaningful choice
* @private
*/
- _hide_control() {
+ _hide_control(): void {
// Hide the entire field wrapper if possible
if (this.$wrapper) {
this.$wrapper.hide();
@@ -166,7 +198,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
* Shows the control
* @private
*/
- _show_control() {
+ _show_control(): void {
if (this.$wrapper) {
this.$wrapper.show();
} else if (this.wrapper) {
@@ -178,14 +210,14 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
* Builds the font select UI
* @private
*/
- _build_ui() {
+ _build_ui(): void {
this._anchor_name = generateAnchorName();
// Generate unique IDs for popover association
const popoverId = `font-select-popover-${anchorCounter}`;
// Build options list for HAST
- const optionItems = this._available_options.map((opt, index) => ({
+ const optionItems = this._available_options.map((opt, index): HastElement => ({
type: "element",
tagName: "button",
properties: {
@@ -199,7 +231,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
}));
// Define complete structure using HAST
- const wrapperStructure = {
+ const wrapperStructure: HastElement = {
type: "element",
tagName: "div",
properties: {
@@ -256,28 +288,40 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
this.font_select_wrapper = toDom(wrapperStructure);
this.input_area.appendChild(this.font_select_wrapper);
- // Get references
- this.trigger_button = this.font_select_wrapper.children[0];
- this.value_display = this.trigger_button.children[0];
- this.popover_element = this.font_select_wrapper.children[1];
- this.options_container = this.popover_element.children[0];
+ // Get references: [button.font-select-trigger > span.font-select-value,
+ // div.font-select-popover > div.font-select-options]
+ const [trigger_button, popover_element] = this.font_select_wrapper.children;
+ const value_display = trigger_button?.children[0];
+ const options_container = popover_element?.children[0];
+ if (
+ !(trigger_button instanceof HTMLButtonElement) ||
+ !(value_display instanceof HTMLSpanElement) ||
+ !(popover_element instanceof HTMLDivElement) ||
+ !(options_container instanceof HTMLDivElement)
+ ) {
+ throw new TypeError("ControlFontSelect: unexpected markup");
+ }
+ this.trigger_button = trigger_button;
+ this.value_display = value_display;
+ this.popover_element = popover_element;
+ this.options_container = options_container;
// Apply font-family styles to option buttons
- const optionButtons = this.options_container.children;
- for (let i = 0; i < optionButtons.length; i++) {
- const btn = optionButtons[i];
- btn.style.fontFamily = btn.dataset.fontFamily;
+ for (const btn of this.option_buttons()) {
+ btn.style.fontFamily = btn.dataset.fontFamily ?? "";
}
// Bind click handlers for options
this.options_container.addEventListener("click", (e) => {
- const optionButton = e.target.closest(".font-select-option");
+ const optionButton =
+ e.target instanceof Element ? e.target.closest(".font-select-option") : null;
if (optionButton) {
const value = optionButton.dataset.value;
this.set_input(value);
this.popover_element.hidePopover();
- if (this.df.onchange) {
- this.df.onchange();
+ const onchange = this.df.onchange;
+ if (typeof onchange === "function") {
+ onchange.call(this.df);
}
}
});
@@ -293,20 +337,23 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
this.input = this.hidden_input;
}
+ /** The option buttons, as rendered into the popover. */
+ option_buttons(): HTMLElement[] {
+ return Array.from(this.options_container.children).filter(
+ (child): child is HTMLElement => child instanceof HTMLElement,
+ );
+ }
+
/**
* Parses options from df.options
* Supports:
* - Array of strings: ["Font A", "Font B"]
* - Array of objects: [{ value: "font-a", label: "Font A", fontFamily: "...", variants: [...] }]
* - Newline-separated string: "Font A\nFont B"
- * @returns {Array<{value: string, label: string, fontFamily: string, variants?: string[]}>}
*/
- parse_options() {
- let options = this.df.options || [];
-
- if (typeof options === "string") {
- options = options.split("\n").filter((o) => o.trim());
- }
+ parse_options(): FontOptionInput[] {
+ const raw = (this.df.options as string | RawFontOption[] | undefined) || [];
+ const options: RawFontOption[] = typeof raw === "string" ? raw.split("\n").filter((o) => o.trim()) : raw;
return options.map((opt) => {
if (typeof opt === "string") {
@@ -317,9 +364,9 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
};
}
return {
- value: opt.value || opt.label,
- label: opt.label || opt.value,
- fontFamily: opt.fontFamily || opt.value,
+ value: opt.value || opt.label || "",
+ label: opt.label || opt.value || "",
+ fontFamily: opt.fontFamily || opt.value || "",
variants: opt.variants,
};
});
@@ -327,40 +374,35 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
/**
* Gets option by value from available options
- * @param {string} value
- * @returns {Object|undefined}
*/
- get_option(value) {
+ get_option(value: unknown): FontOption | undefined {
return this._available_options.find((opt) => opt.value === value);
}
/**
* Gets all available options
- * @returns {Array<{value: string, label: string, fontFamily: string}>}
*/
- get_options() {
+ get_options(): FontOption[] {
return this._available_options;
}
/**
* Returns whether the control is hidden due to insufficient options
- * @returns {boolean}
*/
- is_hidden() {
+ is_hidden(): boolean {
return this._is_hidden;
}
/**
* Waits for async initialization to complete
- * @returns {Promise}
*/
- async ready() {
+ async ready(): Promise {
if (this._init_promise) {
await this._init_promise;
}
}
- set_input(value) {
+ override set_input(value: unknown): void {
this.last_value = this.value;
this.value = value;
@@ -371,14 +413,12 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
}
if (this.hidden_input) {
- this.hidden_input.value = value || "";
+ this.hidden_input.value = value ? String(value) : "";
}
// Update selected state in options
if (this.options_container) {
- const optionButtons = this.options_container.children;
- for (let i = 0; i < optionButtons.length; i++) {
- const btn = optionButtons[i];
+ for (const btn of this.option_buttons()) {
if (btn.dataset.value === value) {
btn.classList.add("selected");
} else {
@@ -388,22 +428,22 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
}
}
- get_input_value() {
+ override get_input_value(): unknown {
return this.value;
}
- get_value() {
+ override get_value(): unknown {
return this.value || this.get_model_value();
}
- set_formatted_input(value) {
+ override set_formatted_input(value: unknown): void {
this.set_input(value);
}
/**
* Refreshes options if they have changed
*/
- async refresh() {
+ override async refresh(): Promise {
super.refresh();
// Wait for initial async setup
@@ -424,7 +464,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
this._is_hidden = true;
this._hide_control();
if (this._available_options.length === 1) {
- this.value = this._available_options[0].value;
+ this.value = this._available_options[0]?.value;
}
} else {
this._is_hidden = false;
@@ -437,7 +477,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
/**
* Rebuilds the options list
*/
- rebuild_options() {
+ rebuild_options(): void {
if (!this.options_container) return;
// Clear existing options
@@ -447,7 +487,7 @@ export class ControlFontSelect extends frappe.ui.form.ControlData {
// Build new options
this._available_options.forEach((opt, index) => {
- const optionStructure = {
+ const optionStructure: HastElement = {
type: "element",
tagName: "button",
properties: {
diff --git a/esign/public/js/controls/signature.js b/esign/public/js/controls/signature.ts
similarity index 84%
rename from esign/public/js/controls/signature.js
rename to esign/public/js/controls/signature.ts
index 8ef3e81..de24ac3 100644
--- a/esign/public/js/controls/signature.js
+++ b/esign/public/js/controls/signature.ts
@@ -2,7 +2,8 @@
// For license information, please see license.txt
import SignaturePad from "signature_pad";
-import { createIconHast, toDom } from "./utils";
+import { ControlData } from "./base";
+import { createIconHast, toDom, type HastElement } from "./utils";
/**
* @fileoverview Signature control for Frappe Framework
@@ -25,11 +26,19 @@ import { createIconHast, toDom } from "./utils";
* @author Avunu LLC
*/
+/** A typed-signature font: its value, its label and the CSS font-family to draw it with. */
+export interface SignatureFont {
+ value: string;
+ label: string;
+ fontFamily: string;
+}
+
+type SignatureMode = "draw" | "type" | "upload";
+
/**
* Bundled signature fonts from @fontsource - always available
- * @type {Array<{value: string, label: string, fontFamily: string}>}
*/
-const SIGNATURE_FONTS = [
+const SIGNATURE_FONTS: readonly [SignatureFont, ...SignatureFont[]] = [
{
value: "Pacifico",
label: "Pacifico",
@@ -63,17 +72,44 @@ let signatureCounter = 0;
* @description A signature field control with draw, type, and upload modes.
* Uses modern web APIs (Popover, CSS Anchor Positioning) and HAST for DOM.
*/
-export class ControlSignature extends frappe.ui.form.ControlData {
+export class ControlSignature extends ControlData {
+ // Assigned in make_input (by toDom refs for the elements), which frappe's control
+ // constructor calls: `declare` keeps the class from re-initialising them afterwards.
+ declare _id: number;
+ declare _current_mode: SignatureMode;
+ declare _font_options: SignatureFont[];
+ declare _selected_font: string | null;
+ declare _font_option_buttons: HTMLElement[];
+ declare _upload_data: string | null;
+ declare _theme_observer: MutationObserver | undefined;
+ declare signature_pad: SignaturePad | undefined;
+ declare mode_buttons: HTMLElement[];
+ declare mode_sections: HTMLElement[];
+ declare display_wrapper: HTMLDivElement;
+ declare display_canvas: HTMLCanvasElement;
+ declare display_overlay: HTMLDivElement;
+ declare dialog: HTMLDivElement;
+ declare pad_canvas: HTMLCanvasElement;
+ declare typed_input: HTMLInputElement;
+ declare font_value_display: HTMLSpanElement;
+ declare font_popover: HTMLDivElement;
+ declare typed_preview_text: HTMLDivElement;
+ declare upload_area: HTMLDivElement;
+ declare file_input: HTMLInputElement;
+ declare upload_prompt: HTMLDivElement;
+ declare upload_preview: HTMLDivElement;
+ declare upload_img: HTMLImageElement;
+
/**
* Gets the term to use for "signature" from df.options.
* Defaults to "Signature" if not specified.
- * @returns {string}
*/
- get_signature_term() {
- return this.df.options?.trim() || "Signature";
+ get_signature_term(): string {
+ const options = this.df.options;
+ return (typeof options === "string" ? options.trim() : "") || "Signature";
}
- make_input() {
+ override make_input(): void {
if (this.has_input) return;
this._id = ++signatureCounter;
@@ -94,14 +130,14 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Builds the signature display area with canvas and overlay
*/
- build_display_area() {
+ build_display_area(): void {
const term = this.get_signature_term().toLowerCase();
const addText = __("Add your {0}", [term]);
const replaceText = __("Replace {0}", [term]);
const addIcon = createIconHast("es-line-add", "md");
const replaceIcon = createIconHast("es-line-edit", "md");
- const displayStructure = {
+ const displayStructure: HastElement = {
type: "element",
tagName: "div",
properties: { className: ["signature-display"] },
@@ -183,7 +219,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Builds the signature dialog as a popover element
*/
- build_dialog_popover() {
+ build_dialog_popover(): void {
const term = this.get_signature_term();
const termLower = term.toLowerCase();
const popoverId = `signature-dialog-${this._id}`;
@@ -193,7 +229,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
this.mode_buttons = [];
this.mode_sections = [];
- const dialogStructure = {
+ const dialogStructure: HastElement = {
type: "element",
tagName: "div",
properties: {
@@ -203,13 +239,13 @@ export class ControlSignature extends frappe.ui.form.ControlData {
},
ref: "dialog",
data: {
- onclick: (e) => {
+ onclick: (e: MouseEvent) => {
// Close on backdrop click
if (e.target === this.dialog) {
this.hide_dialog();
}
},
- onkeydown: (e) => {
+ onkeydown: (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.hide_dialog();
}
@@ -269,7 +305,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataMode: "draw",
},
data: {
- constructor: (el) => this.mode_buttons.push(el),
+ constructor: (el: HTMLElement) => this.mode_buttons.push(el),
onclick: () => this.select_mode("draw"),
},
children: [{ type: "text", value: __("Draw") }],
@@ -283,7 +319,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataMode: "type",
},
data: {
- constructor: (el) => this.mode_buttons.push(el),
+ constructor: (el: HTMLElement) => this.mode_buttons.push(el),
onclick: () => this.select_mode("type"),
},
children: [{ type: "text", value: __("Type") }],
@@ -297,7 +333,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataMode: "upload",
},
data: {
- constructor: (el) => this.mode_buttons.push(el),
+ constructor: (el: HTMLElement) => this.mode_buttons.push(el),
onclick: () => this.select_mode("upload"),
},
children: [{ type: "text", value: __("Upload") }],
@@ -321,7 +357,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataSection: "draw",
},
data: {
- constructor: (el) => this.mode_sections.push(el),
+ constructor: (el: HTMLElement) => this.mode_sections.push(el),
},
children: [
{
@@ -387,7 +423,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataSection: "type",
},
data: {
- constructor: (el) => this.mode_sections.push(el),
+ constructor: (el: HTMLElement) => this.mode_sections.push(el),
},
children: [
{
@@ -511,7 +547,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataSection: "upload",
},
data: {
- constructor: (el) => this.mode_sections.push(el),
+ constructor: (el: HTMLElement) => this.mode_sections.push(el),
},
children: [
{
@@ -522,22 +558,22 @@ export class ControlSignature extends frappe.ui.form.ControlData {
},
ref: "upload_area",
data: {
- onclick: (e) => {
+ onclick: (e: MouseEvent) => {
if (e.target !== this.file_input) {
this.file_input.click();
}
},
- ondragover: (e) => {
+ ondragover: (e: DragEvent) => {
e.preventDefault();
this.upload_area.classList.add("drag-over");
},
ondragleave: () => {
this.upload_area.classList.remove("drag-over");
},
- ondrop: (e) => {
+ ondrop: (e: DragEvent) => {
e.preventDefault();
this.upload_area.classList.remove("drag-over");
- const file = e.dataTransfer.files[0];
+ const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("image/")) {
this.process_uploaded_file(file);
}
@@ -549,12 +585,12 @@ export class ControlSignature extends frappe.ui.form.ControlData {
tagName: "input",
properties: {
type: "file",
- accept: "image/*",
+ accept: ["image/*"],
className: ["signature-file-input"],
},
ref: "file_input",
data: {
- onchange: (e) => this.handle_file_select(e),
+ onchange: (e: Event) => this.handle_file_select(e),
},
children: [],
},
@@ -650,13 +686,14 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Shows the signature dialog
*/
- show_dialog() {
+ show_dialog(): void {
// Initialize font options if not already done (bundled fonts are always available)
if (this._font_options.length === 0) {
- this._font_options = SIGNATURE_FONTS;
+ this._font_options = [...SIGNATURE_FONTS];
this.build_font_options();
- if (this._font_options.length > 0) {
- this.select_font(this._font_options[0].value);
+ const first = this._font_options[0];
+ if (first) {
+ this.select_font(first.value);
}
}
@@ -679,15 +716,15 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Hides the signature dialog
*/
- hide_dialog() {
+ hide_dialog(): void {
this.dialog.hidePopover();
}
/**
* Selects a signature mode (draw, type, upload)
- * @param {string} mode - The mode to select
+ * @param mode - The mode to select
*/
- select_mode(mode) {
+ select_mode(mode: SignatureMode): void {
this._current_mode = mode;
// Update button states
@@ -709,7 +746,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Initializes the signature pad on the draw canvas
*/
- init_signature_pad() {
+ init_signature_pad(): void {
if (!this.pad_canvas) return;
// Resize canvas to match CSS size
@@ -728,7 +765,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Resizes the pad canvas to match CSS dimensions and device pixel ratio
*/
- resize_pad_canvas() {
+ resize_pad_canvas(): void {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const rect = this.pad_canvas.getBoundingClientRect();
@@ -737,7 +774,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
if (this.pad_canvas.width !== rect.width * ratio || this.pad_canvas.height !== rect.height * ratio) {
this.pad_canvas.width = rect.width * ratio;
this.pad_canvas.height = rect.height * ratio;
- this.pad_canvas.getContext("2d").scale(ratio, ratio);
+ this.pad_canvas.getContext("2d")?.scale(ratio, ratio);
if (this.signature_pad) {
this.signature_pad.clear();
@@ -748,15 +785,15 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Builds font option buttons in the font popover
*/
- build_font_options() {
+ build_font_options(): void {
// Track font option buttons for select state updates
this._font_option_buttons = [];
- const optionsStructure = {
+ const optionsStructure: HastElement = {
type: "element",
tagName: "div",
properties: { className: ["signature-font-options"] },
- children: this._font_options.map((opt) => ({
+ children: this._font_options.map((opt): HastElement => ({
type: "element",
tagName: "button",
properties: {
@@ -765,7 +802,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
dataValue: opt.value,
},
data: {
- constructor: (el) => {
+ constructor: (el: HTMLElement) => {
this._font_option_buttons.push(el);
el.style.fontFamily = opt.fontFamily;
},
@@ -784,9 +821,9 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Selects a font for typed signatures
- * @param {string} value - The font value to select
+ * @param value - The font value to select
*/
- select_font(value) {
+ select_font(value: string): void {
this._selected_font = value;
const option = this._font_options.find((o) => o.value === value);
@@ -808,7 +845,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Updates the typed signature preview
*/
- update_typed_preview() {
+ update_typed_preview(): void {
const text = this.typed_input.value || "";
const option = this._font_options.find((o) => o.value === this._selected_font);
const fontFamily = option?.fontFamily || SIGNATURE_FONTS[0].fontFamily;
@@ -819,10 +856,10 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Handles file selection from input
- * @param {Event} e - The change event
+ * @param e - The change event
*/
- handle_file_select(e) {
- const file = e.target.files[0];
+ handle_file_select(e: Event): void {
+ const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
this.process_uploaded_file(file);
}
@@ -830,13 +867,14 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Processes an uploaded file and shows preview
- * @param {File} file - The uploaded file
+ * @param file - The uploaded file
*/
- process_uploaded_file(file) {
+ process_uploaded_file(file: File): void {
const reader = new FileReader();
- reader.addEventListener("load", (e) => {
- this._upload_data = e.target.result;
- this.upload_img.src = this._upload_data;
+ reader.addEventListener("load", () => {
+ const data = reader.result as string;
+ this._upload_data = data;
+ this.upload_img.src = data;
this.upload_preview.classList.add("active");
this.upload_prompt.classList.add("hidden");
});
@@ -846,7 +884,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Handles the insert button click
*/
- handle_insert() {
+ handle_insert(): void {
const term = this.get_signature_term().toLowerCase();
if (this._current_mode === "draw") {
@@ -878,7 +916,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
});
return;
}
- this.normalize_upload_to_canvas(this._upload_data).then((data) => {
+ void this.normalize_upload_to_canvas(this._upload_data).then((data) => {
this.set_signature_value(data);
});
return; // Don't hide yet, wait for promise
@@ -889,10 +927,10 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Converts typed text to base64 PNG
- * @param {string} text - The text to convert
- * @returns {string} Base64 data URL
+ * @param text - The text to convert
+ * @returns Base64 data URL
*/
- convert_typed_to_base64(text) {
+ convert_typed_to_base64(text: string): string {
const option = this._font_options.find((o) => o.value === this._selected_font);
const fontFamily = option?.fontFamily || SIGNATURE_FONTS[0].fontFamily;
@@ -900,6 +938,9 @@ export class ControlSignature extends frappe.ui.form.ControlData {
canvas.width = 750;
canvas.height = 292;
const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return canvas.toDataURL("image/png");
+ }
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -933,15 +974,19 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Normalizes an uploaded image to a consistent canvas size
- * @param {string} dataUrl - The uploaded image data URL
- * @returns {Promise} Normalized base64 data URL
+ * @param dataUrl - The uploaded image data URL
+ * @returns Normalized base64 data URL
*/
- normalize_upload_to_canvas(dataUrl) {
+ normalize_upload_to_canvas(dataUrl: string): Promise {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = 750;
canvas.height = 292;
const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Canvas 2D is not available"));
+ return;
+ }
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -978,11 +1023,11 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Sets the signature value and updates the display
- * @param {string} data - Base64 signature data
+ * @param data - Base64 signature data
*/
- set_signature_value(data) {
+ set_signature_value(data: string): void {
this.value = data;
- this.set_value(data);
+ void this.set_value(data);
this.render_display();
this.hide_dialog();
@@ -997,7 +1042,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
* Canvas 2D can't parse `var(--x)`, so we read the resolved color off a
* hidden probe element — reading `color` guarantees a concrete rgb() value.
*/
- resolve_css_color(var_name, fallback = "#d1d8dd") {
+ resolve_css_color(var_name: string, fallback = "#d1d8dd"): string {
const probe = document.createElement("span");
probe.style.color = `var(${var_name}, ${fallback})`;
probe.style.display = "none";
@@ -1011,7 +1056,7 @@ export class ControlSignature extends frappe.ui.form.ControlData {
* Re-renders the empty-state placeholder when the desk theme changes so its
* border tracks the active theme. Set up once per control instance.
*/
- setup_theme_observer() {
+ setup_theme_observer(): void {
if (this._theme_observer) return;
this._theme_observer = new MutationObserver(() => {
if (!this.get_value()) this.render_display();
@@ -1025,15 +1070,16 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Renders the signature on the display canvas
*/
- render_display() {
+ render_display(): void {
if (!this.display_canvas) return;
this.setup_theme_observer();
const ctx = this.display_canvas.getContext("2d");
+ if (!ctx) return;
ctx.clearRect(0, 0, this.display_canvas.width, this.display_canvas.height);
const value = this.get_value();
- if (value) {
+ if (typeof value === "string" && value) {
const img = new Image();
img.addEventListener("load", () => {
ctx.drawImage(img, 0, 0, this.display_canvas.width, this.display_canvas.height);
@@ -1049,8 +1095,9 @@ export class ControlSignature extends frappe.ui.form.ControlData {
/**
* Renders the empty state on the display canvas
*/
- render_empty_display() {
+ render_empty_display(): void {
const ctx = this.display_canvas.getContext("2d");
+ if (!ctx) return;
ctx.clearRect(0, 0, this.display_canvas.width, this.display_canvas.height);
ctx.setLineDash([5, 5]);
@@ -1065,16 +1112,16 @@ export class ControlSignature extends frappe.ui.form.ControlData {
// Base class interface methods
// ─────────────────────────────────────────────────────────────────
- set_input(value) {
+ override set_input(value: unknown): void {
this.value = value;
this.render_display();
}
- get_input_value() {
+ override get_input_value(): unknown {
return this.value;
}
- get_value() {
+ override get_value(): unknown {
const value = this.value || this.get_model_value();
// Ignore placeholder image
if (value === "/assets/frappe/images/signature-placeholder.png") {
@@ -1083,11 +1130,11 @@ export class ControlSignature extends frappe.ui.form.ControlData {
return value;
}
- set_formatted_input(value) {
+ override set_formatted_input(value: unknown): void {
this.set_input(value);
}
- refresh_input() {
+ override refresh_input(): void {
// Call base class to handle label, description, mandatory styling, etc.
super.refresh_input();
diff --git a/esign/public/js/controls/upload.js b/esign/public/js/controls/upload.ts
similarity index 57%
rename from esign/public/js/controls/upload.js
rename to esign/public/js/controls/upload.ts
index 8ffed92..5e7dbf4 100644
--- a/esign/public/js/controls/upload.js
+++ b/esign/public/js/controls/upload.ts
@@ -1,8 +1,6 @@
// Copyright (c) 2026, Avunu LLC and contributors
// For license information, please see license.txt
-import { toDom } from "hast-util-to-dom";
-
/**
* @fileoverview Upload control for Frappe Framework
* @description This module provides a file upload control with preview functionality.
@@ -13,13 +11,28 @@ import { toDom } from "hast-util-to-dom";
* @author Avunu LLC
*/
-export class ControlUpload extends frappe.ui.form.ControlData {
- make_input() {
+import { ControlData } from "./base";
+import { toDom, type HastElement } from "./utils";
+
+/** `df.options` of an Upload field, when it restricts the file types. */
+interface UploadOptions {
+ allowed_file_types?: string[];
+}
+
+export class ControlUpload extends ControlData {
+ declare file_input: HTMLInputElement;
+ declare preview_element: HTMLElement;
+ declare preview_img: HTMLImageElement;
+
+ override make_input(): void {
if (this.$input) return;
+ const options = this.df.options as UploadOptions | string | undefined;
+ const allowed = typeof options === "object" ? options.allowed_file_types : undefined;
+
// Define complete structure using hast (HTML Abstract Syntax Tree)
// Structure: div.upload-wrapper > [button.btn-upload, input[type=file], div.upload-preview > img]
- const uploadStructure = {
+ const uploadStructure: HastElement = {
type: "element",
tagName: "div",
properties: { className: ["upload-wrapper"] },
@@ -39,7 +52,7 @@ export class ControlUpload extends frappe.ui.form.ControlData {
properties: {
type: "file",
className: ["hidden"],
- accept: this.df.options?.allowed_file_types?.join(",") || "image/*",
+ accept: allowed?.length ? allowed : ["image/*"],
},
children: [],
},
@@ -63,15 +76,21 @@ export class ControlUpload extends frappe.ui.form.ControlData {
const wrapper = toDom(uploadStructure);
this.input_area.appendChild(wrapper);
- // Get references via property accessors
- // wrapper.children[0] = button.btn-upload
- // wrapper.children[1] = input[type=file]
- // wrapper.children[2] = div.upload-preview
- // wrapper.children[2].children[0] = img.upload-preview-img
- const button = wrapper.children[0];
- this.file_input = wrapper.children[1];
- this.preview_element = wrapper.children[2];
- this.preview_img = this.preview_element.children[0];
+ // Get references by position:
+ // [button.btn-upload, input[type=file], div.upload-preview > img.upload-preview-img]
+ const [button, file_input, preview] = wrapper.children;
+ const preview_img = preview?.children[0];
+ if (
+ !(button instanceof HTMLButtonElement) ||
+ !(file_input instanceof HTMLInputElement) ||
+ !(preview instanceof HTMLElement) ||
+ !(preview_img instanceof HTMLImageElement)
+ ) {
+ throw new TypeError("ControlUpload: unexpected markup");
+ }
+ this.file_input = file_input;
+ this.preview_element = preview;
+ this.preview_img = preview_img;
// Bind event listeners
this.file_input.addEventListener("change", (e) => {
@@ -90,40 +109,40 @@ export class ControlUpload extends frappe.ui.form.ControlData {
this.set_input_attributes();
}
- handle_file_selection(e) {
- const file = e.target.files[0];
+ handle_file_selection(e: Event): void {
+ const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
// Convert to base64
const reader = new FileReader();
- reader.addEventListener("load", (event) => {
- const dataurl = event.target.result;
- this.set_value(dataurl);
+ reader.addEventListener("load", () => {
+ const dataurl = reader.result as string;
+ void this.set_value(dataurl);
this.value = dataurl;
this.set_preview(dataurl);
});
reader.readAsDataURL(file);
}
- set_preview(dataurl) {
- if (dataurl) {
+ set_preview(dataurl: unknown): void {
+ if (typeof dataurl === "string" && dataurl) {
this.preview_img.src = dataurl;
this.preview_element.classList.add("active");
- this.$input.textContent = __("Change");
+ this.$input?.text(__("Change"));
} else {
this.preview_element.classList.remove("active");
- this.$input.textContent = __("Upload");
+ this.$input?.text(__("Upload"));
}
}
- set_input(value) {
+ override set_input(value: unknown): void {
this.last_value = this.value;
this.value = value;
this.set_formatted_input(value);
this.set_preview(value);
}
- get_value() {
+ override get_value(): unknown {
return this.value || null;
}
}
diff --git a/esign/public/js/controls/utils.js b/esign/public/js/controls/utils.js
deleted file mode 100644
index 2b2339c..0000000
--- a/esign/public/js/controls/utils.js
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright (c) 2026, Avunu LLC and contributors
-// For license information, please see license.txt
-
-import { toDom as hastToDom } from "hast-util-to-dom";
-
-/**
- * @fileoverview Shared utilities for eSign controls
- * @description Provides HAST-based utilities for DOM structure creation.
- *
- * @requires hast-util-to-dom - For efficient DOM structure creation
- *
- * @author Avunu LLC
- */
-
-/**
- * Converts HAST to DOM with support for element refs and event bindings.
- *
- * Supports ref pattern:
- * - String ref (preferred): `ref: "myElement"` → assigns to `context.myElement`
- *
- * Supports constructor callback:
- * - Callback constructor: `data: { constructor: (el) => ... }` → calls function with element
- *
- * Event listeners are assigned via `data` property:
- * - `data: { onclick: (e) => ... }`
- *
- * @param {Object} hast - HAST structure
- * @param {Object} [context] - Object to assign string refs to (typically `this`)
- * @returns {Element} DOM element
- */
-export function toDom(hast, context) {
- return hastToDom(hast, {
- afterTransform(node, element) {
- // Handle string ref: assign element to context property
- if (node.ref && typeof node.ref === "string" && context) {
- context[node.ref] = element;
- }
- // Handle data properties (callback refs, events, etc.)
- if (node.data && element instanceof Element) {
- for (const [key, value] of Object.entries(node.data)) {
- if (key === "constructor" && typeof value === "function") {
- value(element); // Call ref callback with element
- } else {
- element[key] = value; // Assign event handlers
- }
- }
- }
- },
- });
-}
-
-/**
- * Creates a HAST node for an SVG icon compatible with Frappe's icon system.
- * Supports both standard icons (icon-*) and Espresso icons (es-*).
- *
- * @param {string} icon_name - The name of the icon (e.g., "edit", "add", "es-line-reload")
- * @param {string|{width: string, height: string}} [size="sm"] - Size preset ("xs", "sm", "md", "lg") or custom dimensions
- * @param {string} [icon_class=""] - Additional class for the