A view-driven PCF (Power Apps Component Framework) grid for Dynamics 365 / Dataverse. Columns come from a saved view, resolved from Dataverse metadata at runtime. Which view, which child entity, which buttons appear, which fields are editable, and what has to be true before a write lands are a JSON entry per parent entity — not a code change.
The grid in use — inline editing straight from the form, config-driven validation on the way to the server, and a command bar assembled per entity.
Product.Grid.mp4
Dynamics ships an editable grid, and it edits inline perfectly well. That was never the problem.
The problem is that there is nowhere to put client-side logic. The behaviour this needed — validation that reaches through a lookup to another table before the write lands, a command bar that calls your own business logic, one entity behaving two ways depending on a field on its parent — has no extension point on the stock control:
- Business rules only fire when the conditional column is also a column on the grid, and they don't cross tables. "Compare this quantity against stock on the related product" isn't expressible.
- Editable-grid JavaScript events (
OnChange,OnSave,OnRecordSelect) belong to the classic Editable Grid, which Microsoft has deprecated in favour of the Power Apps grid control. - The Power Apps grid customizer API is the supported replacement, and it is deliberately narrow: it swaps the React element for a cell in read or edit mode, and the docs are explicit that customizers must be pure and "don't use them to mutate data or metadata of the grid." One customizer control per grid, per environment. It is a rendering hook, not a behaviour hook.
That leaves a plugin — a server round-trip and a generic error after the fact — or hand-written script per entity, with the per-entity differences scattered across views, business rules, ribbon rules and script libraries.
So this control owns the render path. Not because inline editing is hard or missing, but because owning it is the only place the logic can live. Once the grid is yours, "quantity may not exceed the product's available stock, counting what this row already reserves" is a JSON entry instead of a fork.
Validation before the write, not after. validationRules are evaluated before every PATCH. A rule compares a left and right operand — the changed value, another field on the row, a field on a related record reached through a lookup, or a constant. Related records are pre-fetched in parallel and de-duplicated. Rules sort by order and short-circuit on the first failure, so the bad value never reaches the server.
Delete guards. deleteRules run before any deleteRecord call, and can be scoped to "this would delete the last remaining line" and gated on a condition against the parent record.
A command bar that calls your code. New / Edit / Delete / Refresh / Export are configurable per entity — reorder, disable, or hide them. Export writes a real .xlsx via SheetJS, including a headers-only file when there are no rows. Custom buttons call any function you expose from a D365 web resource, gated on selection, parent status, or row count, with optional confirmation.
Variants: one entity, several behaviours. A parent entity can select a different view, form, width, and button set based on the value of one of its own fields. Same entity, two behaviours, no second config entry and no code.
Per-entity config, no branching. There is no if (entityName === 'quote') anywhere in component or service code. Supporting a new entity is a config block plus a Dataverse view.
Editing, as table stakes. Text, memo, integer, decimal, money, boolean, date-time, and option-set columns each get a typed renderer. An edit commits on blur or Enter: the UI updates optimistically, the PATCH fires, and a failure rolls the cell back to its last known-good value and shows a row-level error banner. This is the part the stock grid also does — it's here because the config surface above needs a grid that answers to it.
These are the parts worth reading the source for.
Validation rules are data. The rule engine in ValidationService.ts evaluates a small declarative grammar — { lhs, operator, rhs } where each operand is one of changedValue, rowField, relatedField, or constant. "Quantity may not exceed the product's available stock" is a config entry that walks a lookup to another table, not a hand-written check. addRowField lets an operand add a row value to the fetched one, which is how "available stock plus what this row already reserves" is expressed declaratively.
Everything that varies per entity is JSON. Three files (entities.json, buttons.json, fields.json) declare it, and ConfigService resolves them at runtime — including variant selection, which fetches the deciding field from the parent record and merges the matching case over the base.
Renderer registry, not a switch. renderer-registry.ts maps AttributeType → renderer, editability, and sortability. Unmapped types degrade to read-only rather than throwing. Adding support for a type is a registry line and a renderer component.
Views are read, not reimplemented. Point it at a savedQueryId and ViewService.ts reads the FetchXML and LayoutXML, resolves each column's attribute metadata, and picks a renderer by Dataverse AttributeType. Hidden (ishidden="1") columns are dropped. Link-entity columns render read-only, with metadata resolved from the source entity. The view stays the source of truth for what the grid shows.
Metadata straight from the Web API. PCF's context.utils.getEntityMetadata() returns an unreliable Attributes collection, so MetadataService calls the Web API directly and caches through a TTL cache. That's a deliberate deviation from the documented API, and the reason is worth knowing before you "simplify" it.
PATCH logic lives in exactly one place. handleFieldChange owns optimistic UI, no-op suppression, and the rollback baseline; executeFieldPatch owns every Web API write, its bookkeeping, and its error handling. Splitting these is what keeps rollback correct.
index.ts PCF entry — reads form context, mounts React
└── GridErrorBoundary localised fallback; a render crash degrades, not blanks
└── ViewDrivenGrid orchestrator: services, state, save/delete, command bar
├── command-bar/ button assembly, selection gating, custom dispatch
├── GridRow row shell — saving shimmer, saved/error banners
│ └── GridCell
│ └── renderers/ one per Dataverse AttributeType
└── FieldPopoverWrapper hover ⓘ callout with copy-to-clipboard
Services are constructed once in the ViewDrivenGrid constructor and passed to renderers by props — renderers never build their own.
| Service | Responsibility |
|---|---|
ViewService |
Saved-query metadata: FetchXML + LayoutXML parsing, link-entity mapping, hidden-column filtering |
MetadataService |
Entity/attribute metadata via Web API (PCF's own accessor is unreliable) |
OptionSetService |
Picklist option loading and label resolution, multi-level cached |
ConfigService |
Resolves the effective entity config, including variant selection from the parent record |
ValidationService |
Evaluates save-time and delete-time rules, with parallel de-duped related-record fetches |
CacheService |
Static TTL cache (5 min) with targeted eviction |
LoggingService |
Structured, source-tagged console diagnostics |
WebResourceLoader |
On-demand D365 web-resource script loading and dotted-path function resolution |
The control is placed on a form, where a subgrid would normally go.
PCF requires a bound property for the form designer to offer a control at all, so the manifest declares boundField — bind it to any unused SingleLine.Text field on the form. The control never reads or writes it; it exists to satisfy the framework.
Parent context is auto-detected from the form: index.ts reads context.page.entityId and context.page.entityTypeName, which is how one control instance knows both which record it hangs off and which config block applies. The savedQueryId, entityName, parentFieldName, and parentRecordId manifest inputs are last-resort fallbacks for cases where config can't supply them — they are not a second config system.
| Property | Type | Usage | Purpose |
|---|---|---|---|
boundField |
SingleLine.Text |
bound, required | Form-designer requirement only — bind to any unused text field |
savedQueryId |
SingleLine.Text |
input, optional | Fallback view GUID |
entityName |
SingleLine.Text |
input, optional | Fallback child entity logical name |
parentRecordId |
SingleLine.Text |
input, optional | Fallback parent GUID |
parentFieldName |
SingleLine.Text |
input, optional | Fallback parent lookup field |
The manifest requires the WebAPI, Utility, and Navigation features.
Three files, resolved and merged at runtime.
entities.json — keyed by parent entity logical name:
"quote": {
"savedQueryId": "<view guid>",
"entityName": "quotedetail",
"parentFieldName": "quoteid",
"editFormId": "<form guid>",
"editFormWidth": 60,
"refreshParentFormOnSave": true,
"validationRules": [
{
"order": 5,
"triggerField": "quantity",
"lhs": { "source": "changedValue" },
"operator": "eq",
"rhs": { "source": "constant", "value": null },
"message": "Quantity is required."
}
]
}A rule that reaches through a lookup to validate against another table — the thing the stock grid can't be told to do:
{
"order": 30,
"triggerField": "quantity",
"lhs": { "source": "changedValue" },
"operator": "gt",
"rhs": { "source": "relatedField", "lookupField": "productid",
"field": "emd_availablestock", "addRowField": "quantity" },
"message": "Quantity exceeds the available stock for the selected product."
}fields.json — per-field overrides: editable, width, format (decimal | integer | currency | percent | text), popover, and popoverAdditionalFields (extra fields shown in the hover callout, each optionally gated by a condition on a related record).
buttons.json — per-entity command bar. Standard buttons take order and disabled; customButtons are described below.
variants — a parent entity can key on one of its own fields: variants.fieldName names the field, cases keys on its value. ConfigService fetches that field from the parent and merges the matching case over the base.
Type definitions for the whole surface live in config-types.ts — it is the most useful single file for understanding what the control can be told to do.
A custom button names a functionName and, optionally, the webResourceName that defines it. WebResourceLoader injects the script from {clientUrl}/WebResources/{name} on first use, then resolves the dotted path against window and falls back to window.top — PCF runs in an iframe while D365 web resources load on the host frame, and both share an origin.
Buttons can be gated on selection (requiresSelection, visibility: "hideWhenNoSelection"), on the parent's statuscode (requiredStatusCodes), or on row count (requiresMinItemCount), and can require confirmation before firing.
Three calling conventions:
| Config | Call |
|---|---|
| default | fn(parentEntityType, parentRecordId) |
passSelectedIds: true |
fn(parentEntityType, parentRecordId, selectedIds) |
callConvention: "itemsAndParent" |
fn(null, itemsData, parentInfo) |
itemsAndParent builds itemsData from the configured itemPayloadFields on each selected row (unwrapping lookups and falling back to _field_value OData annotations), and parentInfo from parentPayloadTemplate, where the sentinel "__parentRecordId__" is replaced with the parent GUID.
Set refreshAfter to reload the grid once the function settles — with refreshOnlyIfTruthy when the function may bail out early (a user cancelling its own confirm dialog shouldn't cost a reload). A throw or rejection surfaces as a UI error instead of vanishing.
The newButton in this repo's config delegates to ProductAddGrid, a sibling control that handles multi-line product entry — a concrete example of the custom-button contract in use.
Owning the render path means re-earning everything the platform gave you for free. This control deliberately doesn't.
Lookups are read-only. renderer-registry.ts maps Lookup, Customer, and Owner to the unsupported renderer. The platform grids are also weak here — there's no supported API for filtering a lookup in an editable grid, and lookup cell customization is explicitly unsupported in the Power Apps grid control's edit mode — but this control doesn't fix that, it sidesteps it. What it does instead is read through a lookup: validate against a related record's field, gate a popover on one, and inject the lookup GUIDs needed to do either.
No paging. The top attribute is stripped from the view's FetchXML and every child row is loaded, then sorted client-side. That's correct for order lines — tens of rows under one parent — and wrong for a main grid.
Not implemented: grouping, aggregation, column filtering, column resize/reorder, card/list reflow for narrow screens, range-select-to-clipboard, nested grids, choice-column colours, business rules. If a grid needs those more than it needs the config surface above, the platform control is the better answer.
Validation is client-side. Rules block the PATCH this control issues. They don't bind anything reaching Dataverse by another route — that still wants a plugin. Treat them as fast, specific feedback for the person typing, not as enforcement.
cd ProductGridComponent
npm install
npm run build # bundles the control; runs ESLint first
npm run lintPackaging into a solution and importing it is environment-specific and deliberately not included here.
This control cannot run in the PCF test harness. It declares WebAPI, Utility, and Navigation as required features and reaches Dataverse for everything it renders — saved-query metadata, attribute metadata, option sets, the record data itself. It also reads context.page for the parent record, which only a real form supplies, and its custom buttons load scripts from a live D365 web-resource endpoint. There is no mock layer and no fetch fallback.
Exercising it means deploying to a Dataverse environment with the schema, saved queries, and web resources it expects. npm run build and npm run lint are what's verifiable standalone.
TypeScript 5.8 (strict) · React 16.14 · Fluent UI 8 (DetailsList) · SheetJS (xlsx) · Power Apps Component Framework · pcf-scripts / webpack 5 · English + Turkish .resx localisation (1033 / 1055)
React and Fluent versions are pinned by the PCF platform, not by preference.