> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Edit Mode

> Guide for adopting the new custom edit mode for object-backed editable DataTable cells.

export const VersionStatus = ({version}) => {
  const isUnreleased = version === "unreleased";
  return <Badge color={isUnreleased ? "orange" : "green"}>
      {isUnreleased ? "Unreleased" : `v${version}`}
    </Badge>;
};

<VersionStatus version="2.7.2" />

<Note>
  This guide covers changes to the **beta** DataTable component. These APIs may continue to evolve before the stable release.
</Note>

## Overview

This guide continues the work started in [Column Types and EditConfig](/docs/web/components/data-table/beta-changes/column-types), which introduced the `editConfig` property. Custom edit mode is a peer to the existing text, number, boolean, select, and multiselect edit experiences, but it is designed for cells whose committed value is an object rather than a scalar.

The DataTable now supports a dedicated **custom edit mode** for object-backed cells:

* **`editConfig.mode: "custom"`** — Opens a multi-field editor surface for the cell
* **`renderEditor`** — Renders your custom editor UI with row context and a typed `controller`
* **`onCommit`** — Receives the final committed object value when the editor closes normally
* **`onDraftUpdate`** — Optional advanced callback for consumers that need every local draft mutation
* **`validateDraft` / `blockOnValidationError` / `onRequestClose` / `surface`** — Optional hooks for validation, close policy, and surface presentation

Custom mode keeps the canonical cell value intact. You do not have to flatten an address, form state, or other object into a string just to make the cell editable.

## Read mode vs edit mode

Custom mode intentionally separates the read and edit paths:

* **`renderCell`** remains the read-mode renderer for the cell.
* **`getCellText`** is the preferred optional plain-text extractor for sorting when your read view is JSX or multiline.
* **`getReadRenderResult`** is the advanced optional read hook when you want one hook to return both rendered read content and a sortable read string together.
* **`renderEditor`** is the edit-mode renderer for the anchored editor surface.

This matters for object-backed values because the display version of a cell is often formatted text or JSX, while the editable version is a small form.

```tsx theme={null}
createColumn("address", {
  headerLabel: "Address",
  renderCell: (value) => renderAddress(value),
  getCellText: (value) => formatAddress(value),
  editConfig: {
    mode: "custom",
    onCommit: (value, rowId) => saveAddress(value, rowId),
    renderEditor: ({ controller }) => {
      return <AddressEditor controller={controller} />;
    },
  },
});
```

Use `getCellText` by default when you already have a separate `renderCell` and just need a plain sortable string. Reach for `getReadRenderResult` only when you need one hook to produce both the rendered read content and the raw sortable string together.

## Editor context and controller API

`renderEditor` receives a context object with:

* `row` — the full row data
* `rowId` — the row identifier as a string
* `value` — the committed canonical value for the cell
* `controller` — typed draft, validation, focus, and lifecycle control for the editor

The controller surface includes:

* `draftValue` / `initialValue`
* `isDirty` / `changedFields`
* `validation`
* `setDraftValue`
* `setDraftField`
* `handleSubmit`
* `submit`
* `discard`
* `requestClose(reason)`
* `setInitialFocus(focus)`

When you want Enter-to-submit behavior inside a custom editor, render the editor contents inside a `<form onSubmit={controller.handleSubmit}>`. The DataTable routes Enter through the nearest enclosing form and calls `requestSubmit()` on that form. If your custom editor does not render a form, Enter does not implicitly submit the draft. Textareas and other controls that need Enter for their own interaction should continue to manage that key normally.

## Close requests and validation

Custom close behavior is explicit:

* `requestClose("submit")`, outside click, close button, and programmatic submit commit the current draft by default.
* `requestClose("escape")` and pressing `Escape` discard the draft by default.
* Pressing `Enter` inside a custom editor submits only when focus is inside a form wired with `onSubmit={controller.handleSubmit}`.
* `onRequestClose(request)` can return `false` to keep the editor open and block the default behavior.

Validation is informational by default. `validateDraft` can return `formError` and `fieldErrors`, but those values do not block closing unless `blockOnValidationError` is `true` or `onRequestClose` intercepts the request.

Surface presentation options now live under `editConfig.surface`.

## Example: custom address editor

This example keeps the address as an object, renders a multiline read view, previews validation inside the editor, and commits updates at the row level when the surface closes. Because the editor is wrapped in `<form onSubmit={controller.handleSubmit}>`, pressing Enter inside a single-line field submits through that form.

```tsx theme={null}
import { Button } from "@servicetitan/anvil2";
import {
  DataTable,
  Flex,
  TextField,
  Textarea,
  createColumnHelper,
  type TableRow,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

type AddressValue = {
  street1: string;
  street2: string;
  city: string;
  state: string;
  postalCode: string;
  deliveryNotes: string;
};

type RowData = {
  id: string;
  customer: string;
  address: AddressValue;
};

const formatAddress = (value: AddressValue) =>
  [
    [value.street1, value.street2].filter(Boolean).join(", "),
    [value.city, [value.state, value.postalCode].filter(Boolean).join(" ")]
      .filter(Boolean)
      .join(", "),
    value.deliveryNotes ? `Notes: ${value.deliveryNotes}` : undefined,
  ]
    .filter(Boolean)
    .join("\n");

const initialData: TableRow<RowData>[] = [
  {
    id: "1",
    customer: "Bayside Coffee",
    address: {
      street1: "188 Market Street",
      street2: "Suite 400",
      city: "San Francisco",
      state: "CA",
      postalCode: "94105",
      deliveryNotes: "Leave with front desk before 3 PM.",
    },
  },
];

function App() {
  const [data, setData] = useState(initialData);
  const createColumn = createColumnHelper<RowData>();

  const columns = [
    createColumn("customer", {
      headerLabel: "Customer",
      sortable: true,
    }),
    createColumn("address", {
      headerLabel: "Address",
      minWidth: 280,
      sortable: true,
      renderCell: (value) => (
        <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
      ),
      getReadRenderResult: (value) => ({
        content: (
          <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
        ),
        rawString: formatAddress(value),
      }),
      editConfig: {
        mode: "custom",
        surface: {
          title: "Edit address",
          width: 360,
          maxHeight: 480,
          closeButtonLabel: "Save address changes",
        },
        blockOnValidationError: true,
        validateDraft: (draftValue) => ({
          formError: draftValue.street1 ? undefined : "Street address is required.",
          fieldErrors: draftValue.street1
            ? undefined
            : { street1: "Enter a street address." },
        }),
        onCommit: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, address: value } : row,
            ),
          );
        },
        renderEditor: ({ controller, rowId }) => {
          const changedFieldsLabel = controller.changedFields
            .map(String)
            .join(", ");

          return (
            // Wrap custom editor contents in a form to enable Enter-to-submit.
            <form onSubmit={controller.handleSubmit}>
              <Flex direction="column" gap={12}>
                <TextField
                  ref={(element) => {
                    controller.setInitialFocus(
                      element ? () => element.focus() : null,
                    );
                  }}
                  label="Street address"
                  value={controller.draftValue.street1}
                  onChange={(event) =>
                    controller.setDraftField("street1", event.target.value)
                  }
                  error={controller.validation.fieldErrors?.street1}
                />
                <TextField
                  label="City"
                  value={controller.draftValue.city}
                  onChange={(event) =>
                    controller.setDraftField("city", event.target.value)
                  }
                />
                <Flex gap={12}>
                  <TextField
                    label="State"
                    maxLength={2}
                    value={controller.draftValue.state}
                    onChange={(event) =>
                      controller.setDraftField(
                        "state",
                        event.target.value.toUpperCase(),
                      )
                    }
                  />
                  <TextField
                    label="Postal code"
                    inputMode="numeric"
                    value={controller.draftValue.postalCode}
                    onChange={(event) =>
                      controller.setDraftField(
                        "postalCode",
                        event.target.value.replace(/[^\d]/g, "").slice(0, 5),
                      )
                    }
                  />
                </Flex>
                <Textarea
                  label="Delivery notes"
                  autoHeight
                  minRows={3}
                  value={controller.draftValue.deliveryNotes}
                  onChange={(event) =>
                    controller.setDraftField("deliveryNotes", event.target.value)
                  }
                />

                {controller.validation.formError ? (
                  <div>{controller.validation.formError}</div>
                ) : null}

                {controller.isDirty ? (
                  <div>
                    Row {rowId} changed fields: {changedFieldsLabel || "none"}
                  </div>
                ) : null}

                <Flex gap={8}>
                  <Button type="submit">Save</Button>
                  <Button type="button" onClick={controller.discard}>
                    Discard
                  </Button>
                </Flex>
              </Flex>
            </form>
          );
        },
      },
    }),
  ];

  return <DataTable data={data} columns={columns} />;
}
```

## Advanced: `onDraftUpdate`

`onDraftUpdate` is optional. Use it when you need draft-side telemetry, live preview, or external validation state on every local edit. It is not required for the normal custom editing flow.

```tsx theme={null}
editConfig: {
  mode: "custom",
  onDraftUpdate: (value, rowId) => {
    syncDraftPreview(value, rowId);
  },
  onCommit: (value, rowId) => saveAddress(value, rowId),
  renderEditor: ({ controller }) => <AddressEditor controller={controller} />,
}
```

## Focus management with `setInitialFocus`

Use `setInitialFocus` to let the table focus one preferred field when the custom editor opens:

```tsx theme={null}
renderEditor: ({ controller }) => {
  return (
    <TextField
      ref={(element) => {
        controller.setInitialFocus(element ? () => element.focus() : null);
      }}
      label="Street"
    />
  );
};
```

Register a single preferred focus callback. Passing a new callback replaces the old one, and passing `null` clears it.
