UI Scripts
Learn how to create UI scripts to customize form and list behavior in Zodula. UI scripts allow you to add client-side logic, calculations, validations, and UI interactions similar to Frappe's client scripts.
Overview
UI Scripts (also known as Client Scripts) allow you to customize the behavior of forms and lists in the Zodula admin interface. They enable you to:
- Set default values when forms load
- Calculate field values based on other fields
- Validate input before saving
- Show/hide fields conditionally
- Format field values for display
- Handle UI interactions like button clicks
UI Scripts are React components that register event handlers using the useUIScriptRegistry hook.
Quick Start
Create a UI script file in your app's ui/scripts directory:
import { useEffect } from "react";
import { useUIScriptRegistry } from "@/zodula/ui/hooks/use-ui-script";
export default function UserScripts() {
const { registerScript } = useUIScriptRegistry();
useEffect(() => {
registerScript("zodula__User", {
id: "user_defaults",
doctype: "zodula__User",
name: "User Defaults",
description: "Set default values for users",
events: [
{
type: "form_load",
action: async (context) => {
if (context.isCreate) {
if (!context.getValue?.("is_active")) {
context.setValue?.("is_active", 1);
}
}
}
}
]
});
}, []);
return null; // This is a script component, not a visual component
}File Location
UI scripts are placed in the ui/scripts directory of your app:
apps/your-app-name/ui/scripts/
├── User.ui.tsx
├── Invoice.ui.tsx
└── InvoiceItem.ui.tsxThe naming convention is: {DoctypeName}.ui.tsx
Script Structure
A UI script consists of:
- Script Metadata:
id,doctype,name,description - Events: Array of event handlers
- Event Handlers: Each with a
type, optionaltarget, optionalcondition, andaction
registerScript("zodula__User", {
id: "script_id", // Unique identifier
doctype: "zodula__User", // Target doctype
name: "Script Name", // Display name
description: "Description", // Optional description
events: [
{
type: "form_load", // Event type
target: "field_name", // Optional: specific field/button
condition: (context) => { // Optional: condition to run
return context.isCreate;
},
action: async (context) => {
// Your logic here
}
}
]
});Available Events
UI Scripts support the following event types:
Form Events
| Event Type | Description | When Triggered |
|---|---|---|
form_load | Form is loaded | When form opens (create or edit) |
form_save | Form is saved | Before saving the form |
form_reset | Form is reset | When form is reset/cleared |
Field Events
| Event Type | Description | When Triggered |
|---|---|---|
field_change | Field value changes | When a field value changes |
field_focus | Field receives focus | When user clicks into a field |
field_blur | Field loses focus | When user clicks out of a field |
List Events
| Event Type | Description | When Triggered |
|---|---|---|
list_load | List is loaded | When list view opens |
list_refresh | List is refreshed | When list data is refreshed |
row_select | Row is selected | When a row is selected |
row_click | Row is clicked | When a row is clicked |
row_edit | Row edit is triggered | When edit button is clicked |
row_delete | Row delete is triggered | When delete button is clicked |
Action Events
| Event Type | Description | When Triggered |
|---|---|---|
button_click | Button is clicked | When a custom button is clicked |
action_execute | Action is executed | When an action is executed |
Formatting Events
| Event Type | Description | When Triggered |
|---|---|---|
on_format | Format field value | When field value needs formatting |
on_render | Render custom component | When custom rendering is needed |
Script Context
The context object passed to event handlers provides access to form data, methods, and utilities:
Common Properties
{
doctype: string; // The doctype name
docId?: string; // Document ID (if editing)
isCreate?: boolean; // True if creating new document
isEdit?: boolean; // True if editing existing document
}Form Methods
{
getValue?: (fieldName: string) => any; // Get field value
getValues?: () => any; // Get all field values
setValue?: (fieldName: string, value: any) => void; // Set field value
setValues?: (values: Record<string, any>) => void; // Set multiple values
formData?: any; // Current form data
}List Methods
{
listData?: any[]; // List data
selectedRows?: Set<string>; // Selected row IDs
setSelectedRows?: (rows: Set<string>) => void; // Set selected rows
refreshList?: () => void; // Refresh list data
}UI Methods
{
showToast?: (message: string, type?: 'success' | 'error' | 'info') => void;
showDialog?: (component: any, props: any) => Promise<any>;
navigate?: (path: string) => void;
}Event-Specific Properties
{
fieldName?: string; // Field that triggered the event
value?: any; // New field value
oldValue?: any; // Previous field value
targetValue?: any; // Target value for comparison
event?: Event; // Original DOM event
}Utility Functions
{
utils?: {
formatCurrency: (value: number) => string;
parseCurrency: (value: string) => number;
formatDate: (date: Date | string) => string;
parseDate: (date: string) => Date;
calculateTotal: (items: any[], quantityField: string, priceField: string) => number;
};
}Examples
Setting Default Values
Set default values when creating a new document:
registerScript("zodula__User", {
id: "user_defaults",
doctype: "zodula__User",
name: "User Defaults",
events: [
{
type: "form_load",
action: async (context) => {
if (context.isCreate) {
if (!context.getValue?.("is_active")) {
context.setValue?.("is_active", 1);
}
if (!context.getValue?.("created_date")) {
context.setValue?.("created_date", new Date().toISOString().split('T')[0]);
}
}
}
}
]
});Field Calculations
Calculate field values based on other fields:
registerScript("zerp__Invoice Item", {
id: "invoice_item_calculations",
doctype: "zerp__Invoice Item",
name: "Invoice Item Calculations",
events: [
{
type: "field_change",
target: "quantity",
action: async (context) => {
const quantity = parseFloat(context.value) || 0;
const unitPrice = parseFloat(context.getValue?.("unit_price")) || 0;
const totalPrice = quantity * unitPrice;
context.setValue?.("total_price", totalPrice);
}
},
{
type: "field_change",
target: "unit_price",
action: async (context) => {
const unitPrice = parseFloat(context.value) || 0;
const quantity = parseFloat(context.getValue?.("quantity")) || 0;
const totalPrice = quantity * unitPrice;
context.setValue?.("total_price", totalPrice);
}
}
]
});Complex Calculations with Child Tables
Calculate totals from child table items:
registerScript("zerp__Invoice", {
id: "invoice_calculations",
doctype: "zerp__Invoice",
name: "Invoice Calculations",
events: [
{
type: "field_change",
target: "invoice_items",
action: async (context) => {
const items = context.getValue?.("invoice_items") || [];
if (Array.isArray(items)) {
const totalAmount = context.utils?.calculateTotal(items, 'quantity', 'unit_price') || 0;
const exchangeRate = parseFloat(context.getValue?.("exchange_rate")) || 1;
const currencyAmount = totalAmount * exchangeRate;
context.setValue?.("total_amount", totalAmount);
context.setValue?.("currency_amount", currencyAmount);
}
}
},
{
type: "field_change",
target: "exchange_rate",
action: async (context) => {
const items = context.getValue?.("invoice_items") || [];
if (Array.isArray(items)) {
const totalAmount = context.utils?.calculateTotal(items, 'quantity', 'unit_price') || 0;
const exchangeRate = parseFloat(context.value) || 1;
const currencyAmount = totalAmount * exchangeRate;
context.setValue?.("currency_amount", currencyAmount);
}
}
}
]
});Conditional Field Updates
Update fields based on conditions:
registerScript("zodula__User", {
id: "user_role_updates",
doctype: "zodula__User",
name: "User Role Updates",
events: [
{
type: "field_change",
target: "role",
action: async (context) => {
if (context.value === "Admin") {
context.setValue?.("has_admin_permission", 1);
context.showToast?.("Admin permissions enabled", "info");
} else {
context.setValue?.("has_admin_permission", 0);
}
}
}
]
});Validation Before Save
Validate form data before saving:
registerScript("zodula__User", {
id: "user_validation",
doctype: "zodula__User",
name: "User Validation",
events: [
{
type: "form_save",
action: async (context) => {
const email = context.getValue?.("email");
if (email && !email.includes("@")) {
context.showToast?.("Invalid email address", "error");
throw new Error("Invalid email address");
}
const password = context.getValue?.("password");
if (password && password.length < 8) {
context.showToast?.("Password must be at least 8 characters", "error");
throw new Error("Password too short");
}
}
}
]
});Multiple Events in One Script
Register multiple event handlers in a single script:
registerScript("zerp__Invoice", {
id: "invoice_complete",
doctype: "zerp__Invoice",
name: "Invoice Complete Script",
events: [
{
type: "form_load",
action: async (context) => {
if (context.isCreate) {
// Set default invoice date
if (!context.getValue?.("invoice_date")) {
context.setValue?.("invoice_date", new Date().toISOString().split('T')[0]);
}
// Set default due date (30 days from now)
if (!context.getValue?.("due_date")) {
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 30);
context.setValue?.("due_date", dueDate.toISOString().split('T')[0]);
}
// Set default exchange rate
if (!context.getValue?.("exchange_rate")) {
context.setValue?.("exchange_rate", 1);
}
}
}
},
{
type: "field_change",
target: "invoice_items",
action: async (context) => {
// Recalculate totals when items change
const items = context.getValue?.("invoice_items") || [];
if (Array.isArray(items)) {
const total = context.utils?.calculateTotal(items, 'quantity', 'unit_price') || 0;
context.setValue?.("total_amount", total);
}
}
}
]
});Using Conditions
Run actions only when certain conditions are met:
registerScript("zodula__User", {
id: "user_conditional",
doctype: "zodula__User",
name: "Conditional User Script",
events: [
{
type: "field_change",
target: "is_active",
condition: (context) => {
// Only run if user is being deactivated
return context.value === 0;
},
action: async (context) => {
context.showToast?.("User will be deactivated", "warning");
// Additional logic for deactivation
}
}
]
});Best Practices
1. Use Descriptive IDs
Use clear, descriptive IDs for your scripts:
// ✅ Good
id: "invoice_calculations"
id: "user_defaults"
id: "product_price_updates"
// ❌ Bad
id: "script1"
id: "test"
id: "temp"2. Group Related Logic
Group related event handlers in the same script:
// ✅ Good: All invoice calculations in one script
registerScript("zerp__Invoice", {
id: "invoice_calculations",
events: [
{ type: "field_change", target: "items", ... },
{ type: "field_change", target: "exchange_rate", ... }
]
});
// ❌ Bad: Split across multiple scripts unnecessarily3. Handle Async Operations
Always use async/await for asynchronous operations:
{
type: "field_change",
action: async (context) => {
// Fetch data from API
const data = await fetch("/api/data").then(r => r.json());
context.setValue?.("field", data.value);
}
}4. Check for Optional Methods
Always check if methods exist before calling them:
// ✅ Good
if (context.getValue) {
const value = context.getValue("field");
}
// ❌ Bad - May throw error if method doesn't exist
const value = context.getValue("field");5. Use Utility Functions
Leverage built-in utility functions for common operations:
// ✅ Good - Use utility function
const total = context.utils?.calculateTotal(items, 'qty', 'price') || 0;
// ❌ Bad - Manual calculation
const total = items.reduce((sum, item) => sum + (item.qty * item.price), 0);6. Provide User Feedback
Use toast notifications to inform users:
{
type: "field_change",
action: async (context) => {
context.setValue?.("status", "updated");
context.showToast?.("Status updated successfully", "success");
}
}Common Patterns
Pattern: Auto-calculate Totals
{
type: "field_change",
target: "items",
action: async (context) => {
const items = context.getValue?.("items") || [];
const total = context.utils?.calculateTotal(items, 'quantity', 'price') || 0;
context.setValue?.("total", total);
}
}Pattern: Set Defaults on Create
{
type: "form_load",
action: async (context) => {
if (context.isCreate) {
context.setValue?.("status", "Draft");
context.setValue?.("date", new Date().toISOString().split('T')[0]);
}
}
}Pattern: Conditional Field Updates
{
type: "field_change",
target: "type",
action: async (context) => {
if (context.value === "Customer") {
context.setValue?.("is_customer", 1);
context.setValue?.("is_supplier", 0);
} else if (context.value === "Supplier") {
context.setValue?.("is_customer", 0);
context.setValue?.("is_supplier", 1);
}
}
}Pattern: Validate and Show Errors
{
type: "form_save",
action: async (context) => {
const value = context.getValue?.("field");
if (!value || value < 0) {
context.showToast?.("Field must be positive", "error");
throw new Error("Validation failed");
}
}
}Next Steps
- Learn about Extend Scripts - Server-side extensions
- Explore DocType Events - Server-side event handling
- Check DocTypes - Understanding doctype structure
Zodula