Page size
Full Name | Email | Phone | Age | Account | |
|---|---|---|---|---|---|
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
|
Set AllowEdit="true" on MainGrid / SubGrid and a toggle labelled Editable appears in the gear-menu overflow. Flipping it on switches every declared column whose cell renderer isn't overridden into an inline editor — auto-dispatched from the column's Dataverse metadata — while a GridTemplateColumn's EditChildContent takes over from its read-mode ChildContent. Edits accumulate as pending row changes in the grid's transactional buffer and only land in Dataverse when the user saves.
A MainGrid over the contact table with AllowEdit="true". Click the gear icon on the toolbar overflow and flip Editable on. The Full Name column swaps its initials-avatar read template for a custom single-field editor that splits user input back into firstname + lastname. The bare Email, Phone, Age, and Account columns each light up with the editor matching their metadata type — TextEdit for the strings, NumberEdit for the integer, LookupEdit for the customer reference — without any per-column wiring.
Page size
Full Name | Email | Phone | Age | Account | |
|---|---|---|---|---|---|
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
| |||||
|
|
Set AllowEdit="true" on the grid. Editing is opt-in per grid — the toggle only appears when the parameter is set, and the toggle itself is only visible when at least one column is editable. While the toggle is off, the grid behaves as a read-only view; while it's on, cells render their inline editors and unsaved changes are tracked per row.
<MainGrid tableName="contact" allowEdit>
<GridColumns>
<GridColumn columnName="firstname" />
<GridColumn columnName="lastname" />
<GridColumn columnName="emailaddress1" />
</GridColumns>
</MainGrid><MainGrid TableName="contact" AllowEdit="true">
<GridColumns>
<GridColumn ColumnName="firstname" />
<GridColumn ColumnName="lastname" />
<GridColumn ColumnName="emailaddress1" />
</GridColumns>
</MainGrid>Why a toggle?
Inline edit changes the cell rendering for every visible row, which is a different read affordance than a static grid. The toggle lets users opt into the edit affordance when they need it (typing a value, tabbing across cells, etc.) and keep the cleaner read affordance the rest of the time. The grid remembers the toggle state for the session.
Bare <GridColumn ColumnName="..." /> declarations with no ChildContent auto-dispatch the right inline editor while the grid is in edit mode. The framework reads the column's metadata and picks the editor subclass:
String, Memo, EmailAddress, Phone, Url — TextEdit (with format-appropriate validation).Integer, Decimal, Double, BigInt — NumberEdit (locale-aware parsing, min/max from metadata).Boolean — BoolEdit (rendered as a switch, centered alignment).Picklist — ChoiceEdit (option-set-driven dropdown, localized labels).MultiSelectPicklist — MultiSelectChoiceEdit.Lookup, Customer, Owner — LookupEdit (typeahead-driven, respects target-table allow lists).DateTime, DateOnly — DateTimeEdit.Money — MoneyEdit (currency-symbol prefix from transactioncurrencyid).File, Image — FileEdit / ImageEdit (drag-drop upload).Supply ChildContent on a GridColumn to take over both the read-mode display AND the edit-mode editor for that column. Once ChildContent is set, the framework stops auto-dispatching the metadata-driven editor — render whichever editor you want inside the template and gate its visibility off the grid's Editable flag. Standard editors (TextEdit, NumberEdit, ColumnEdit) dropped into the template auto-wire to dirty-tracking and validation via the cascading row context.
{/* Custom display cell PLUS custom editor. React splits the two
halves into cellRenderer (read-only) and editRenderer (edit mode) —
use <GridTemplateColumn> so both renderers can coexist for the
same logical column. The dropped-in <TextEdit> self-registers for
dirty-tracking and validation via the cascading row context. */}
<GridTemplateColumn
displayName="Email"
dependsOn={['emailaddress1']}
sortBy="emailaddress1"
cellRenderer={({ record }) => {
const email = (record.properties?.emailaddress1 as { value?: string })?.value;
return <span>{email}</span>;
}}
editRenderer={() => (
<TextEdit columnName="emailaddress1" displayLabelWhenAvailable={false} />
)}
/><!-- Custom display cell PLUS custom editor. Once ChildContent is
supplied, the framework no longer auto-dispatches an inline
editor for this column, so render one yourself if you want
the cell editable. The dropped-in editor self-registers for
dirty-tracking and validation via the cascading row context. -->
<GridColumn ColumnName="emailaddress1">
<ChildContent>
@{
var email = context.PrimaryRecord
.GetValueOrDefault<StringValue>("emailaddress1")?.Value;
}
@if (this.Editable)
{
<TextEdit ColumnName="emailaddress1"
DisplayLabelWhenAvailable="false"
DisplayTooltipWhenAvailable="false" />
}
else
{
<span>@email</span>
}
</ChildContent>
</GridColumn>Each row maintains its own EditContextValidator. Standard editors register with it on mount and surface their errors inline (red underline + tooltip on the cell). The grid's save button is disabled while any row has an unresolved validation error. Validation rules come from the column metadata:
RequiredLevel — the column is required, and an empty value blocks save.Format on string columns — email, phone, URL formats enforce shape on top of the type.MinValue / MaxValue on numeric columns — out-of-range values are flagged before save.EditContextValidator API for cross-field invariants (e.g. "contact must have either email or phone").Validation on custom widgets
Custom inputs rendered inside
ChildContent/EditChildContentthat don't extendBaseEdit(e.g. a rawFluentTextFieldor a third-party widget) don't auto-register with the row's validator. If you need them validated, either drop a standard editor instead of the raw widget, or run validation manually inside the widget'sValueChangedcallback before callingeditCtx.SetValue.
Edits accumulate as pending updates on the row — modified cells get a small "dirty" indicator, deleted rows are strikethrough, and pending-create rows show as new. The grid's toolbar surfaces Save and Cancel buttons whenever there are pending changes: Save commits the batch in one transactional pass; Cancel discards the buffer and reverts each row to its server state. Pair with NewRecordGridButton and DeleteRecordGridButton in the Buttons fragment to add row creation and deletion to the same edit transaction.
<MainGrid tableName="contact" allowEdit>
<GridColumns>
<GridColumn columnName="firstname" />
<GridColumn columnName="lastname" />
<GridColumn columnName="emailaddress1" />
</GridColumns>
<GridButtons>
<NewRecordGridButton>
<NewContactForm />
</NewRecordGridButton>
<DeleteRecordGridButton />
</GridButtons>
</MainGrid><MainGrid TableName="contact" AllowEdit="true">
<GridColumns>
<GridColumn ColumnName="firstname" />
<GridColumn ColumnName="lastname" />
<GridColumn ColumnName="emailaddress1" />
</GridColumns>
<Buttons>
<NewRecordGridButton TForm="NewContactForm" />
<DeleteRecordGridButton />
</Buttons>
</MainGrid>Pair a GridTemplateColumn's read-mode ChildContent with an EditChildContent to make a synthetic / composite column editable. The edit template receives a GridTemplateColumnEditContext — same row context as the read template (editCtx.Row.PrimaryRecord, the optional editCtx.Row.ParentRecord in a SubGrid, editCtx.DependsOnMetadata) plus an imperative editCtx.SetValue(columnName, value) helper.
EditChildContent fires only when the grid is editable (AllowEdit="true" AND the Editable toggle is on) and the row isn't pending-delete. Template columns that omit EditChildContent stay read-only even in edit mode — useful for actions columns that should always render the same buttons.
The most common case: the cell composes from multiple bound columns, and you want to let the user edit each one. Drop the matching <TextEdit> / <NumberEdit> / <ColumnEdit> editors inside EditChildContent — the framework cascades the row's primary record and matching EditContextValidator into that scope, so the editors self-register for dirty-tracking and validation. No extra wiring required.
<GridTemplateColumn
displayName="Full Name"
dependsOn={['firstname', 'lastname']}
sortBy="lastname"
cellRenderer={({ record }) => {
const first = (record.properties?.firstname as { value?: string })?.value ?? '';
const last = (record.properties?.lastname as { value?: string })?.value ?? '';
return <strong>{first} {last}</strong>;
}}
// Drop-in editors — TextEdit auto-wires to dirty-tracking
// and validation via the cascading row context.
editRenderer={() => (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<TextEdit columnName="firstname" displayLabelWhenAvailable={false} />
<TextEdit columnName="lastname" displayLabelWhenAvailable={false} />
</div>
)}
/><GridTemplateColumn Title="Full Name"
DependsOn="@(new[] { "firstname", "lastname" })"
SortBy="lastname">
<ChildContent Context="ctx">
<strong>
@(ctx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("firstname")?.Value)
@(ctx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("lastname")?.Value)
</strong>
</ChildContent>
<!-- Drop-in editors — TextEdit auto-wires to dirty-tracking
and validation via the cascading row context. -->
<EditChildContent Context="editCtx">
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="6"
VerticalAlignment="VerticalAlignment.Center">
<TextEdit ColumnName="firstname"
DisplayLabelWhenAvailable="false"
DisplayTooltipWhenAvailable="false" />
<TextEdit ColumnName="lastname"
DisplayLabelWhenAvailable="false"
DisplayTooltipWhenAvailable="false" />
</FluentStack>
</EditChildContent>
</GridTemplateColumn>When the editor surface doesn't map 1:1 to a Dataverse column — a single "First Last" text box that parses into two columns, a slider tied to multiple percent fields, a custom date-range picker writing start + end — render your own input and call editCtx.SetValue(columnName, value) for each affected column. The framework routes the write to the correct AliasedTableRecord and fires ValueChanged so dirty-tracking picks the change up.
<GridTemplateColumn
displayName="Full Name"
dependsOn={['firstname', 'lastname']}
sortBy="lastname"
cellRenderer={({ record }) => {
const first = (record.properties?.firstname as { value?: string })?.value ?? '';
const last = (record.properties?.lastname as { value?: string })?.value ?? '';
return <strong>{first} {last}</strong>;
}}
editRenderer={({ record, setValue }) => {
const first = (record.properties?.firstname as { value?: string })?.value ?? '';
const last = (record.properties?.lastname as { value?: string })?.value ?? '';
const combined = [first, last].filter(Boolean).join(' ');
// Custom widget — split on space; consumer takes on
// validation since <Input> doesn't self-register.
return (
<Input
value={combined}
onChange={(_, data) => {
const next = data.value ?? '';
const idx = next.indexOf(' ');
const head = idx === -1 ? next : next.slice(0, idx);
const tail = idx === -1 ? '' : next.slice(idx + 1);
setValue('firstname', { $type: 'StringValue', value: head });
setValue('lastname', { $type: 'StringValue', value: tail || null });
}}
/>
);
}}
/><GridTemplateColumn Title="Full Name"
DependsOn="@(new[] { "firstname", "lastname" })"
SortBy="lastname">
<ChildContent Context="ctx">
@{
var first = ctx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("firstname")?.Value;
var last = ctx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("lastname")?.Value;
}
<strong>@first @last</strong>
</ChildContent>
<EditChildContent Context="editCtx">
@{
var combined =
(editCtx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("firstname")?.Value ?? "")
+ " "
+ (editCtx.Row.PrimaryRecord.GetValueOrDefault<StringValue>("lastname")?.Value ?? "");
}
<!-- Custom widget — split on space; consumer takes on
validation since FluentTextField doesn't self-register. -->
<FluentTextField Value="@combined.Trim()" ValueChanged="@(next =>
{
var parts = next.Split(' ', 2);
editCtx.SetValue("firstname", new StringValue { Value = parts[0] });
editCtx.SetValue("lastname", new StringValue { Value = parts.Length > 1 ? parts[1] : null });
})" />
</EditChildContent>
</GridTemplateColumn>Validation on the imperative path
Standard editors dropped into
EditChildContentregister with the row'sEditContextValidatorautomatically and participate in the framework's validate-before-save gate. TheeditCtx.SetValuepath bypasses that pipeline — values that didn't come from a registered editor aren't validated. If your custom widget needs to enforce constraints, run them inside theValueChangedcallback before callingSetValue, or fall back to the drop-in editor pattern.