ZodulaZodula
Basic

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.tsx

The naming convention is: {DoctypeName}.ui.tsx

Script Structure

A UI script consists of:

  1. Script Metadata: id, doctype, name, description
  2. Events: Array of event handlers
  3. Event Handlers: Each with a type, optional target, optional condition, and action
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 TypeDescriptionWhen Triggered
form_loadForm is loadedWhen form opens (create or edit)
form_saveForm is savedBefore saving the form
form_resetForm is resetWhen form is reset/cleared

Field Events

Event TypeDescriptionWhen Triggered
field_changeField value changesWhen a field value changes
field_focusField receives focusWhen user clicks into a field
field_blurField loses focusWhen user clicks out of a field

List Events

Event TypeDescriptionWhen Triggered
list_loadList is loadedWhen list view opens
list_refreshList is refreshedWhen list data is refreshed
row_selectRow is selectedWhen a row is selected
row_clickRow is clickedWhen a row is clicked
row_editRow edit is triggeredWhen edit button is clicked
row_deleteRow delete is triggeredWhen delete button is clicked

Action Events

Event TypeDescriptionWhen Triggered
button_clickButton is clickedWhen a custom button is clicked
action_executeAction is executedWhen an action is executed

Formatting Events

Event TypeDescriptionWhen Triggered
on_formatFormat field valueWhen field value needs formatting
on_renderRender custom componentWhen 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"

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 unnecessarily

3. 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