# graftpunk v1.6.0 — Plugin Author Guide: Multi-View Rendering

## What's New

v1.6.0 adds **multi-view rendering** to the formatter system. Commands can now define multiple named views on their response data, and the framework handles rendering them across all output formats.

### New Capabilities

| Feature | Description |
|---------|-------------|
| **Multi-view tables** | Multiple views render as separate Rich table sections with titled Rule headers |
| **XLSX export** | New `--format xlsx` writes Excel workbooks with one worksheet per view |
| **`--view` CLI option** | Users can cherry-pick views and filter columns: `--view items:id,name` |
| **CSV multi-view warning** | CSV warns when multiple views exist and suggests `--view` to select one |
| **Tuple immutability** | `OutputConfig.views`, `ColumnFilter.columns`, and `ViewConfig.display` are now tuples |

### New Imports

```python
# These were available before but are now more useful:
from graftpunk.plugins.output_config import OutputConfig, ViewConfig, ColumnFilter
from graftpunk.plugins.cli_plugin import CommandResult
```

---

## What the BEK Plugin Already Does Well

`invoice list` and `order list` already use `OutputConfig` with a single `ViewConfig` — they're using the pattern correctly. The data is structured as a dict with a nested list (`invoices` or `orders`) plus metadata fields (`account`, `total`, `page`, etc.), and the `ViewConfig` extracts the list via `path` and curates columns via `ColumnFilter`.

This is exactly the foundation multi-view builds on.

---

## Opportunity: Add Views for Pagination/Account Metadata

Both `invoice list` and `order list` return pagination and account metadata alongside the main data, but the current single-view config discards it. Multi-view lets you show both.

### Before (current — single view)

```python
# _invoice.py — invoice list
return CommandResult(
    data={
        "invoices": invoices,
        "account": first_account["accountNumber"],
        "account_name": first_account["accountName"],
        "total_due": first_account["totalAmountDue"],
        "total": pagination["totalNumberOfResults"],
        "page": pagination["currentPage"],
        "pages": pagination["numberOfPages"],
    },
    format_hint="table",
    output_config=OutputConfig(
        views=[
            ViewConfig(
                name="default",
                path="invoices",
                columns=ColumnFilter(
                    mode="include",
                    columns=[
                        "invoiceId",
                        "salesOrderId",
                        "orderCode",
                        "invoiceDate",
                        "invoiceStatus",
                        "amount",
                        "invoiceDueDate",
                    ],
                ),
            )
        ],
        default_view="default",
    ),
)
```

**Output:** A single table of invoices. The account name, total due, and pagination info are invisible.

### After (multi-view)

```python
return CommandResult(
    data={
        "invoices": invoices,
        "account": first_account["accountNumber"],
        "account_name": first_account["accountName"],
        "total_due": first_account["totalAmountDue"],
        "total": pagination["totalNumberOfResults"],
        "page": pagination["currentPage"],
        "pages": pagination["numberOfPages"],
    },
    format_hint="table",
    output_config=OutputConfig(
        views=[
            ViewConfig(
                name="invoices",
                path="invoices",
                title="Invoices",
                columns=ColumnFilter(
                    mode="include",
                    columns=[
                        "invoiceId",
                        "salesOrderId",
                        "orderCode",
                        "invoiceDate",
                        "invoiceStatus",
                        "amount",
                        "invoiceDueDate",
                    ],
                ),
            ),
            ViewConfig(
                name="summary",
                title="Account Summary",
                # No path — renders the top-level dict minus "invoices"
                columns=ColumnFilter(
                    mode="exclude",
                    columns=["invoices"],
                ),
            ),
        ],
        default_view="invoices",
    ),
)
```

**Output with `--format table`:**

```
──────────────────── Invoices ────────────────────
┌───────────┬──────────────┬───────────┬─────────────┬───────────────┬────────────────────┬────────────────┐
│ invoiceId │ salesOrderId │ orderCode │ invoiceDate │ invoiceStatus │ amount             │ invoiceDueDate │
├───────────┼──────────────┼───────────┼─────────────┼───────────────┼────────────────────┼────────────────┤
│ 123       │ 456          │ ORD789    │ 2026-02-09  │ OPEN          │ $111.63 ($50 due)  │ 2026-02-23     │
└───────────┴──────────────┴───────────┴─────────────┴───────────────┴────────────────────┴────────────────┘

──────────────── Account Summary ─────────────────
┌──────────────┬─────────┐
│ account      │ TEST001 │
│ account_name │ Test Co │
│ total_due    │ $100.00 │
│ total        │ 1       │
│ page         │ 0       │
│ pages        │ 1       │
└──────────────┴─────────┘
```

**Output with `--format xlsx`:** Two worksheets — "Invoices" and "Account Summary".

### What Users Can Do

```bash
# Default: show all views
gp bek invoice list

# Show only invoices (no summary)
gp bek invoice list --view invoices

# Show only specific columns
gp bek invoice list --view invoices:invoiceId,amount,invoiceDueDate

# Export to Excel with both worksheets
gp bek invoice list -f xlsx
```

---

## Same Pattern for Order List

```python
# _order.py — order list
return CommandResult(
    data={
        "orders": orders,
        "total": pagination["totalNumberOfResults"],
        "page": pagination["currentPage"],
        "pages": pagination["numberOfPages"],
    },
    format_hint="table",
    output_config=OutputConfig(
        views=[
            ViewConfig(
                name="orders",
                path="orders",
                title="Orders",
                columns=ColumnFilter(
                    mode="include",
                    columns=[
                        "code",
                        "salesOrderId",
                        "orderDate",
                        "shippingDate",
                        "statusDisplay",
                        "totalItems",
                        "totalPrice",
                        "submittedBy",
                        "source",
                        "customerNumber",
                    ],
                ),
            ),
            ViewConfig(
                name="pagination",
                title="Page Info",
                columns=ColumnFilter(
                    mode="exclude",
                    columns=["orders"],
                ),
            ),
        ],
        default_view="orders",
    ),
)
```

---

## How Multi-View Interacts With Each Format

| Format | Behavior | Notes |
|--------|----------|-------|
| `table` | Each view is a separate section with a titled Rule divider. Single-view commands render clean (no header). | This is the main use case. |
| `xlsx` | Each view becomes a separate worksheet tab. Tab name uses `view.title` (truncated to 31 chars). | Great for data that users want to open in Excel. |
| `csv` | Only renders the **default view**. If multiple views exist, warns and suggests `--view`. | CSV is flat — can't represent multiple sections. |
| `json` | Ignores views entirely — renders the full data structure. | JSON always shows everything. Users can `--format json` to see what's really there. |
| `raw` | Ignores views — renders as-is. | Passthrough format. |

---

## ViewConfig Reference

```python
@dataclass(frozen=True)
class ViewConfig:
    name: str              # Identifier used with --view (required, non-empty)
    path: str = ""         # Dot-separated extraction path (e.g., "invoices" or "data.items")
    title: str = ""        # Display title for section headers and XLSX tabs
    columns: ColumnFilter | None = None  # Optional column include/exclude filter
    display: tuple[ColumnDisplayConfig, ...] = ()  # Column display config (future use)
```

### Key Points

- **`path`** extracts nested data. `path="invoices"` on `{"invoices": [...], "total": 5}` yields just the list. Empty path means the whole object.
- **`title`** is what shows in the Rich Rule header and XLSX tab name. Falls back to `name` if empty.
- **`columns`** with `mode="include"` shows only listed columns. `mode="exclude"` hides listed columns. `None` shows all columns.
- **`name`** is what users pass to `--view`. Keep it short and lowercase.

### ColumnFilter

```python
@dataclass(frozen=True)
class ColumnFilter:
    mode: Literal["include", "exclude"]
    columns: tuple[str, ...]  # Accepts list or tuple; stored as tuple
```

---

## OutputConfig Reference

```python
@dataclass(frozen=True)
class OutputConfig:
    views: tuple[ViewConfig, ...]  # Accepts list or tuple; stored as tuple
    default_view: str = ""         # Name of the default view (used by CSV)
```

- **`default_view`** determines which view CSV renders when multiple views exist.
- **`filter_views(names, column_overrides)`** is called internally by `--view`. You don't need to call it.

---

## Upgrading Existing Commands

### Minimum Change (rename "default" → meaningful name)

If you just want to give the existing single view a real name so `--view` works better:

```python
# Before
ViewConfig(name="default", path="invoices", ...)

# After
ViewConfig(name="invoices", path="invoices", title="Invoices", ...)
```

This alone enables `--view invoices:col1,col2` column filtering for users.

### Full Multi-View (add metadata views)

Add a second `ViewConfig` for the metadata/pagination that's already in the response dict. See the examples above.

### Adding XLSX Support

No code changes needed — it works automatically. If your command returns `OutputConfig` with views, `--format xlsx` creates one worksheet per view. Users just pass `-f xlsx`.

---

## Commands That Could Benefit From Views

| Command | Current | Opportunity |
|---------|---------|-------------|
| `invoice list` | Single "default" view on `invoices` | Add "summary" view for account/pagination metadata |
| `order list` | Single "default" view on `orders` | Add "pagination" view for page info |
| `invoice detail` | Three hard-coded format branches (json/csv/bekentree) | Could add an "items" table view alongside the JSON default |
| `search` | Returns flat list with `format_hint="table"` | Could add `OutputConfig` with column curation (hide `category` by default, let users `--view` it back) |
| `notifications` | Returns flat list with `format_hint="table"` | Could curate columns (e.g., hide `active`, `showCta`, `header` by default) |

### Lower Priority

`account summary`, `cart get`, `delivery_dates`, `favorites`, `inventory`, `lists` — these return raw JSON and are mostly exploratory/debugging commands. Adding views wouldn't add much value.

---

## Breaking Changes to Watch For

### Tuple Fields

`ColumnFilter.columns` and `OutputConfig.views` now store tuples internally, even if you pass lists. This is transparent — you can still pass `columns=[...]` and `views=[...]` — but if any code reads `.columns` or `.views` and assumes `list`, it may need updating.

```python
# This still works fine:
ColumnFilter(mode="include", columns=["invoiceId", "amount"])

# But if you do this somewhere:
config.views.append(...)  # AttributeError — it's a tuple now
```

### `get_downloads_dir()` Returns Absolute Path

The XLSX download directory is now always resolved to an absolute path. This shouldn't affect plugin code since plugins don't call this directly.

### `format_hint` Accepts "xlsx"

`CommandResult.format_hint` now accepts `"xlsx"` as a valid literal value. You probably don't want to default any command to XLSX (it writes a file), but it's available if a command is specifically about exporting data.

---

## Version Requirement

To use multi-view features, require graftpunk >= 1.6.0:

```toml
# pyproject.toml
dependencies = [
    "graftpunk>=1.6.0",
]
```
