# Abstra Forms — Annotated Examples

This file is for AI assistants only. Study these examples before writing any Form stage code.
Each example is a complete, production-quality script demonstrating real-world patterns.

---

## Example 1: Support Ticket with Live Validation

**Patterns:** reactive page with `errors=[]`, live character count, computation step
**SDKs:** `abstra.tables`, `abstra.messages`
**Widgets:** EmailInput, DropdownInput, TextInput, TextOutput, MarkdownOutput

```python
from abstra.forms import (
    run,
    TextInput, EmailInput, DropdownInput, TextOutput, MarkdownOutput,
)
from abstra.tables import insert, run_sql
from abstra.messages import send_email

print("=== Support Ticket Form ===")

CATEGORIES = ["Billing", "Technical", "Account", "Feature Request", "Other"]
MIN_DESCRIPTION_LENGTH = 30

# Reactive page: re-executes on every keystroke to provide live feedback.
# IMPORTANT: no side effects here — only pure rendering logic.
def ticket_form(state):
    email = state.get("email", "")
    subject = state.get("subject", "")
    description = state.get("description", "")

    email_errors = ["Enter a valid email address"] if email and "@" not in email else []

    subject_errors = (
        [f"Too short ({len(subject)}/5 chars minimum)"]
        if subject and len(subject) < 5
        else []
    )

    desc_errors = (
        [f"Too short ({len(description)}/{MIN_DESCRIPTION_LENGTH} chars minimum)"]
        if description and len(description) < MIN_DESCRIPTION_LENGTH
        else []
    )

    widgets: list[Widget] = [
        EmailInput(key="email", label="Your email", required=True, errors=email_errors),
        DropdownInput(key="category", label="Category", options=CATEGORIES, required=True),
        TextInput(key="subject", label="Subject", required=True, errors=subject_errors),
        TextInput(key="description", label="Describe the issue", required=True, errors=desc_errors),
    ]

    # Live character count — only show once the user starts typing
    if description:
        widgets.append(TextOutput(f"{len(description)} characters", size="small"))

    return widgets

# Computation step: save ticket and send confirmation email.
# Runs exactly once when the user clicks Next — never inside the reactive page above.
def save_ticket(state):
    print(f"Creating ticket for {state['email']} — {state['category']}")

    # Warn on duplicates: already-open ticket from the same email in the same category.
    # run_sql uses Postgres positional placeholders ($1, $2, …) with a params list.
    # %s and ? are NOT interpreted — the query is sent to Postgres as-is.
    open_tickets = run_sql(
        'SELECT id FROM "support_tickets" WHERE email = $1 AND category = $2 AND status = $3',
        [state["email"], state["category"], "open"],
    )
    if open_tickets:
        print(f"Note: {len(open_tickets)} open ticket(s) already exist for this email/category")

    record = insert("support_tickets", {
        "email": state["email"],
        "category": state["category"],
        "subject": state["subject"],
        "description": state["description"],
        "status": "open",
    })

    state["ticket_id"] = record["id"]
    print(f"Ticket #{record['id']} saved")

    send_email(
        to=state["email"],
        title=f"[Ticket #{record['id']}] {state['subject']}",
        message=(
            f"Your support ticket has been received.\n\n"
            f"Ticket: {record['id']}\n"
            f"Category: {state['category']}\n\n"
            f"We'll reply within 1 business day."
        ),
    )
    print("Confirmation email sent")

def confirmation(state):
    return [MarkdownOutput(f"""
## Ticket Submitted!

**Ticket:** {state['ticket_id']}
**Category:** {state['category']}
**Subject:** {state['subject']}

A confirmation was sent to **{state['email']}**.
    """)]

state = run([ticket_form, save_ticket, confirmation])
print(f"=== Ticket #{state['ticket_id']} created ===")
```

---

## Example 2: Purchase Request Approval

**Patterns:** `get_user()`, `get_tasks()`, canonical Button approve/reject pattern (return None to advance silently)
**SDKs:** `abstra.tasks`, `abstra.tables`, `abstra.messages`
**Widgets:** MarkdownOutput, TextInput, Button

```python
from abstra.forms import run, get_user, MarkdownOutput, TextInput, Button
from abstra.tasks import get_tasks
from abstra.tables import update
from abstra.messages import send_email

print("=== Purchase Approval Form ===")

user = get_user()
print(f"Logged in as: {user.email}")

# get_tasks() is the correct API for Forms — never use get_trigger_task() here
pending = [t for t in get_tasks() if t["approver_email"].lower() == user.email.lower()]
print(f"Pending approvals: {len(pending)}")

if not pending:
    run([[MarkdownOutput(f"No pending approvals for **{user.email}**.")]])
    exit()

task = pending[0]
purchase_id = task["purchase_id"]
requester_email = task["requester_email"]
requester_name = task["requester_name"]
amount = task["amount"] / 100  # stored in cents
description = task["description"]

# Single reactive page that handles both initial display and post-button branches.
# When a button is clicked, state[key] becomes True (boolean) — not a string.
# Returning None from the approve/reject branches makes this act as a computation
# step for that execution — the form advances silently without rendering.
def approval_page(state):
    if state.get("approve"):
        # User clicked Approve: show optional note field
        return [
            MarkdownOutput(f"Approving **R$ {amount:,.2f}** — add a note if needed."),
            TextInput(key="note", label="Approval note (optional)", required=False),
        ]

    if state.get("reject"):
        # User clicked Reject: require a reason before proceeding
        return [
            MarkdownOutput(f"Rejecting **R$ {amount:,.2f}**"),
            TextInput(key="reason", label="Rejection reason", required=True),
        ]

    # Initial state: show details with action buttons (replaces default Next/Back)
    return [
        MarkdownOutput(f"""## Purchase Request #{purchase_id}

**Requester:** {requester_name}
**Description:** {description}
**Amount:** R$ {amount:,.2f}
        """),
    ], [Button("Approve", key="approve"), Button("Reject", key="reject")]

# Computation step: persist decision and notify requester
def process_decision(state):
    if state.get("approve"):
        print(f"Approving purchase #{purchase_id}")
        update(
            "purchases",
            where={"id": purchase_id},
            set={"status": "approved", "approver": user.email, "note": state.get("note", "")},
        )
        send_email(
            to=requester_email,
            title=f"Purchase #{purchase_id} Approved",
            message=(
                f"Hi {requester_name},\n\n"
                f"Your purchase request of R$ {amount:,.2f} has been approved.\n\n"
                f"Note: {state.get('note') or 'No note added.'}"
            ),
        )
        task.complete()
        print("Approved and task completed")

    elif state.get("reject"):
        print(f"Rejecting purchase #{purchase_id}")
        update(
            "purchases",
            where={"id": purchase_id},
            set={"status": "rejected", "approver": user.email, "reason": state.get("reason", "")},
        )
        send_email(
            to=requester_email,
            title=f"Purchase #{purchase_id} Rejected",
            message=(
                f"Hi {requester_name},\n\n"
                f"Your purchase request of R$ {amount:,.2f} was not approved.\n\n"
                f"Reason: {state.get('reason', 'N/A')}"
            ),
        )
        task.complete()
        print("Rejected and task completed")

run([approval_page, process_decision])
print("=== Approval complete ===")
```

---

## Example 3: Invoice Entry with AI Extraction

**Patterns:** generator step for progress during AI processing, HtmlOutput for styled review
**SDKs:** `abstra.ai`, `abstra.tables`
**Widgets:** TextareaInput, ProgressOutput, MarkdownOutput, HtmlOutput, CheckboxInput

> Uploading a file? Build this as a **Page**, not a Form. In a Page the file bytes arrive in-memory (`file["content"]`) and you persist them with `get_persistent_dir()`. A Form's `FileInput` returns a `FileResponse` whose `.path` / `.name` / `.content` / `.file` re-materialize the upload lazily and break across step transitions — so this example takes the invoice as pasted text instead. See the Pages guide via `get_stage_guide("page", ...)`.

```python
from datetime import date, timedelta

from abstra.forms import (
    run,
    TextareaInput, ProgressOutput, MarkdownOutput, HtmlOutput, CheckboxInput,
)
from abstra.ai import prompt
from abstra.tables import insert, select, delete_by_id, is_gte

print("=== Invoice Entry Form ===")

DUPLICATE_LOOKBACK_DAYS = 365

# Page 1: Paste invoice text — static page
entry_page = [
    MarkdownOutput("## Invoice Entry\n\nPaste the invoice text to automatically extract and record its data."),
    TextareaInput(
        key="invoice_text",
        label="Invoice text",
        required=True,
        hint="The AI will extract vendor, amount, dates and description.",
    ),
]

# Generator step: shows live progress pages while the AI runs on the server.
# Between yield calls the server is doing work; the user sees the previous yield.
# Only output widgets work here — no user input is possible during a generator step.
def extract_invoice_data(state):
    yield [
        MarkdownOutput("## Analyzing Invoice"),
        ProgressOutput(current=1, total=3, text="Reading input..."),
    ]

    # Run prompt() directly on the pasted text. For Brazilian fiscal docs
    # (NF-e, NFS-e, boleto) prefer the specialized parse_* functions.
    invoice_text = state["invoice_text"]

    yield [
        MarkdownOutput("## Analyzing Invoice"),
        ProgressOutput(current=2, total=3, text="Running AI extraction..."),
    ]

    extracted = prompt(
        [invoice_text, "Extract all invoice fields accurately."],
        format={
            "invoice_number": str,
            "issue_date": str,
            "due_date": str,
            "vendor_name": str,
            "vendor_cnpj": str,
            "total_amount": float,
            "currency": str,
            "description": str,
        },
    )

    state["extracted"] = extracted
    print(f"Extracted: invoice #{extracted.get('invoice_number')} from {extracted.get('vendor_name')}")

    yield [
        MarkdownOutput("## Analyzing Invoice"),
        ProgressOutput(current=3, total=3, text="Done!"),
    ]

# Computation step: check whether this invoice_number + vendor_cnpj already
# exists in the last year so the review page can warn the user.
def check_duplicate(state):
    ext = state["extracted"]
    cutoff = (date.today() - timedelta(days=DUPLICATE_LOOKBACK_DAYS)).isoformat()
    existing = select(
        "invoices",
        where={
            "invoice_number": ext.get("invoice_number"),
            "vendor_cnpj": ext.get("vendor_cnpj"),
            "issue_date": is_gte(cutoff),
        },
        limit=1,
    )
    state["duplicate"] = existing[0] if existing else None
    if existing:
        print(f"Duplicate detected: invoice {ext.get('invoice_number')} already on record (id={existing[0]['id']})")

# Reactive page: shows extracted data with HtmlOutput for a styled card layout
def review_extracted(state):
    ext = state.get("extracted") or {}
    amount = ext.get("total_amount") or 0
    currency = ext.get("currency") or "R$"
    duplicate = state.get("duplicate")

    # HtmlOutput renders arbitrary HTML — great for styled summaries and custom layouts
    summary_html = f"""
    <div style="font-family: sans-serif; padding: 16px; background: #f8fafc;
                border: 1px solid #e2e8f0; border-radius: 8px; line-height: 1.6;">
        <h3 style="margin: 0 0 12px; color: #1e293b;">Extracted Invoice Data</h3>
        <table style="width: 100%; border-collapse: collapse;">
            <tr><td style="padding: 4px 8px; color: #64748b;">Invoice #</td>
                <td style="padding: 4px 8px; font-weight: 600;">{ext.get('invoice_number', '—')}</td></tr>
            <tr><td style="padding: 4px 8px; color: #64748b;">Vendor</td>
                <td style="padding: 4px 8px; font-weight: 600;">{ext.get('vendor_name', '—')}</td></tr>
            <tr><td style="padding: 4px 8px; color: #64748b;">CNPJ</td>
                <td style="padding: 4px 8px;">{ext.get('vendor_cnpj', '—')}</td></tr>
            <tr><td style="padding: 4px 8px; color: #64748b;">Issue Date</td>
                <td style="padding: 4px 8px;">{ext.get('issue_date', '—')}</td></tr>
            <tr><td style="padding: 4px 8px; color: #64748b;">Due Date</td>
                <td style="padding: 4px 8px;">{ext.get('due_date', '—')}</td></tr>
            <tr style="background: #f0fdf4;">
                <td style="padding: 4px 8px; color: #64748b; font-weight: 600;">Amount</td>
                <td style="padding: 4px 8px; font-weight: 700; color: #166534;">
                    {currency} {amount:,.2f}
                </td></tr>
            <tr><td style="padding: 4px 8px; color: #64748b;">Description</td>
                <td style="padding: 4px 8px;">{ext.get('description', '—')}</td></tr>
        </table>
    </div>
    """

    widgets: list[Widget] = [HtmlOutput(summary_html, full_width=True)]
    if duplicate:
        widgets.append(
            HtmlOutput(
                f"""<div style="font-family: sans-serif; padding: 12px 16px;
                            background: #fef3c7; border: 1px solid #fcd34d;
                            border-radius: 8px; color: #78350f;">
                    Heads up: invoice <strong>{ext.get('invoice_number')}</strong>
                    from this vendor is already on record (id #{duplicate['id']}).
                    Check the box below to replace it.
                </div>""",
                full_width=True,
            )
        )
        widgets.append(
            CheckboxInput(
                key="replace_existing",
                label="Replace the existing invoice",
                required=False,
            )
        )
    widgets.append(
        CheckboxInput(
            key="confirmed",
            label="The data above is correct — save to records",
            required=True,
        )
    )
    return widgets

# Computation step: persist to database. If the user opted to replace an
# existing record, delete_by_id wipes the old row before the new insert.
def save_invoice(state):
    ext = state["extracted"]

    duplicate = state.get("duplicate")
    if duplicate and state.get("replace_existing"):
        delete_by_id("invoices", id=duplicate["id"])
        print(f"Replaced existing invoice {duplicate['id']}")

    print(f"Saving invoice #{ext.get('invoice_number')} to database")

    record = insert("invoices", {
        "invoice_number": ext.get("invoice_number"),
        "issue_date": ext.get("issue_date"),
        "due_date": ext.get("due_date"),
        "vendor_name": ext.get("vendor_name"),
        "vendor_cnpj": ext.get("vendor_cnpj"),
        "total_amount_cents": int((ext.get("total_amount") or 0) * 100),
        "currency": ext.get("currency"),
        "description": ext.get("description"),
        "status": "pending_payment",
    })

    state["invoice_id"] = record["id"]
    print(f"Invoice saved with ID: {record['id']}")

def confirmation(state):
    ext = state["extracted"]
    return [MarkdownOutput(f"""
## Invoice Recorded!

**Vendor:** {ext.get('vendor_name')}
**Amount:** {ext.get('currency')} {ext.get('total_amount', 0):,.2f}
**Due Date:** {ext.get('due_date')}
**Record ID:** #{state['invoice_id']}

This invoice has been added to the payment queue.
    """)]

state = run([
    entry_page,
    extract_invoice_data,
    check_duplicate,
    review_extracted,
    save_invoice,
    confirmation,
])
print(f"=== Invoice #{state.get('invoice_id')} processed ===")
```
