A sortable, filterable table over any Dataverse view, with inline editing, pinned columns and a New button.
Reference example · built with AI. This control was written with AI (Claude) and tested on a live Dataverse form; its code has not been reviewed line by line. It is published as a worked example and is not maintained — read the source and
SPEC.md(what was measured on the form) before you use it. Fixes are not guaranteed.
Documentation lives on PCFHub, built
from the docs/ directory in this repository. Edit the Markdown here; the hub
recompiles it.
Data Table binds a dataset — a subgrid, a view, a canvas collection — and renders it as a semantic HTML table with a pager, sortable headers and optional row selection.
The decision that shaped everything else: it declares no property-set
roles. A property-set is a fixed-arity declaration, one named role per line of
manifest, so a role-based table is capped at whatever N the manifest hard-codes
and the maker with N+1 columns is stuck. It also cannot carry the metadata a
table needs — order, visualSizeFactor, isPrimary and disableSorting
exist on real view columns and nowhere else. So the control reads
dataset.columns and renders what the view already says. There is nothing to
configure per column, and the view designer is the configuration UI.
The second decision: paging and sorting go back through the dataset API,
not through the array on screen. sortedRecordIds already is the current page
on a real platform, so slicing it client-side hides records the platform paged
for; and sorting the rows on screen reorders twenty-five out of two hundred and
forty, which is a wrong answer that looks completely right. Both cost a round
trip, and both are correct across the whole result set.
The third is a footgun rather than a design: updateView fires on every
dataset change, including the ones this control caused. setPageSize() does
nothing until the next fetch, so it has to be followed by refresh() — and
refresh() fires updateView. The guard is on a control-instance field rather
than on ds.paging.pageSize, because the platform's own value does not equal
the requested one until the refresh lands. Without it, the control spins. See
SPEC.md.
Two <feature-usage> entries, both required="false". Utility was declared
in 0.4.0 after three releases of none, for getEntityMetadata: it buys the
option list a choice column's editor and filter box are built from, which the
platform hands a dataset control no other way. WebAPI followed in 0.5.0, for
exactly one call — updateRecord, for lookup cells, and nothing else. Device
is untouched, and openDatasetItem() and navigation.openForm() sit on
surfaces no feature gates. So the prompt at import is two items long, and
declining either costs only what that one buys: the choice cells, or the
lookup editor.
Editing still asks nothing of the environment, which is the whole reason it
is built the way it is. The write goes through record.setValue() and
record.save() on the dataset record itself — measured working on a real
subgrid, 2026-09-09, committed and survived a reload; measured again 2026-09-11
for a choice column, integer in and integer read back. Neither method is in
@types/powerapps-component-framework@1.3.18, where EntityRecord declares
four methods and none of them writes, so both are feature-detected at runtime
before an editor is offered. The obvious alternative, webAPI.updateRecord,
needs <uses-feature name="WebAPI" /> — an install-time prompt in every
environment — and does nothing at all in a canvas app.
What that write path does not do is a lookup: setValue on a lookup column
stages nothing, five shapes tried, every save() refused. So since 0.5.0 a
lookup cell is the one exception — picked in the platform's own dialog
(utils.lookupObjects) and written with webAPI.updateRecord through an
@odata.bind key whose navigation property is read from
EntityDefinitions rather than derived, because on the probe table it was the
logical name and not the schema-cased one, and a Customer lookup has two. That
costs the WebAPI feature and makes lookup editing model-driven only; SPEC.md
0.5.0 has the nine measurements it rests on.
The same undocumented surface answers isEditable(column) per column and per
record, which is what lets the control render a cell the user cannot write as
plain text instead of offering an editor and discovering the truth when the save
is refused.
Related through (0.8.0) is the PCF Gallery request board's top open ask, a
subgrid whose rows are related to the form's record by a path its one
relationship cannot express. It is built on dataset.linking.addLinkedEntity,
which nothing in the catalogue had called before. A FetchXML <link-entity>
with {id} is parsed into links and conditions that name them by
entityAliasName, ANDed into every filter the control sets, so the platform
still pages, sorts and counts the rows. A probe build measured all of it on a
subgrid first, and one answer contradicted the documentation. A dataset
numbers the hierarchy operators the PCF reference's way (79 is eq-or-under),
not the SDK's. SPEC.md 0.8.0 has the eleven answers.
| Property | Type | Usage | Default | What it controls |
|---|---|---|---|---|
records |
DataSet | dataset | — | The view or collection to render. No property-set roles: the columns are the view's |
↳ cds-data-set-options |
manifest attribute | — | all three on | Keeps the model-driven subgrid's command bar, view selector and quick find. Design-time only; not a maker-facing property |
pageSize |
Whole.None | input | (unset) | Rows requested per page, clamped to 1–250. Unset adopts the host's own page size and never calls setPageSize |
pageSizeOptions |
SingleLine.Text | input | (unset) | A comma-separated list, e.g. 10,25,50, offered as a rows-per-page picker. Unset means no picker |
enableFiltering |
TwoOptions | input | true |
A filter box under each text, numeric, date and — in a model-driven app — choice column heading, applied server-side |
enableExport |
TwoOptions | input | false |
An Export CSV button covering the rows loaded so far |
selectionMode |
Enum | input | single |
none, single or multiple |
enableSorting |
TwoOptions | input | true |
Show sort controls on the columns the view allows sorting on |
openOnRowClick |
TwoOptions | input | true |
Open the record on row click, as well as from the primary column |
pinnedStart |
Whole.None | input | (unset) | How many of the view's first columns stay put while the table scrolls sideways. Pins the select column with them |
pinnedEnd |
Whole.None | input | (unset) | The same at the other end. One column is always left unpinned, and pinning drops itself entirely where it would leave nothing to scroll |
enableEditing |
TwoOptions | input | false |
Edit a cell in place and save it. Only cells the platform reports as editable are offered |
editableColumns |
SingleLine.Text | input | (unset) | A comma-separated allow-list of logical names. It narrows what the platform permits; it never widens it |
enableCreate |
TwoOptions | input | false |
A New button that opens the table's quick create form. Model-driven only: canvas has no form to open |
groupBy |
SingleLine.Text | input | (unset) | Comma-separated logical names to group the rows by. Unset renders exactly as 0.5.0 did |
aggregates |
SingleLine.Text | input | (unset) | Per-group measures as aggregate:column, e.g. sum:revenue, avg:revenue. A record count is always shown |
groupSort |
Enum | input | label |
Order group headers by their label or by their record count |
parentLookup |
SingleLine.Text | input | (unset) | The lookup pointing at the record a subgrid sits under. Normally unset; set it where the table has several lookups to the same table, or none where the subgrid is unrelated |
relatedThrough |
SingleLine.Text | input | (unset) | Rows related to the form's record by any path: a FetchXML <link-entity> with {id} for the record, inline or as the name of a Data (XML) web resource. Turn off Show related records on the subgrid. Model-driven only; unset renders exactly as 0.7.x |
enableMultiSort |
TwoOptions | input | false |
Shift-click a heading to add that column to the sort order. Off by default, so a plain click behaves exactly as it did |
exportScope |
Enum | input | loaded |
Whether Export CSV writes the rows already loaded or reads view — the whole view — first, at one request per page |
lockColumnWidths |
TwoOptions | input | false |
Stop users resizing columns. Unlocked, a user drags a header's edge (or uses the arrow keys on it) and the width is remembered in their browser, per table and view |
editedRecordId |
SingleLine.Text | output | — | The row most recently saved by an inline edit |
createdRecordId |
SingleLine.Text | output | — | The row most recently created through the New button |
selectedRecordIds |
Multiple | output | — | Selected row IDs, one per line |
openedRecordId |
SingleLine.Text | output | — | The row most recently opened |
Strings ship in five languages — English (1033), German (1031), French (1036),
Japanese (1041) and Spanish (3082) — all carrying the same 134 keys. The four
translations are machine-generated and have not been read by a native speaker;
npm run check proves every key and every {0} placeholder is present in each,
and proves nothing about the wording. Corrections are welcome as a patch.
React and Fluent UI come from the platform
as <platform-library> entries rather than from the bundle — the build confirms
it, emitting Reactv16 and FluentUIReactv940 as webpack externals.
demo.fidelity is limited. That is now because of a few named features,
not because of the table as a whole.
Since pcfhub/pcfhub#51, the hub's demo harness keeps a dataset's page, sort, filter and selection between renders. It applies them on the next fetch, as a form does, so paging, sorting and filtering work against the fixture. So does opening a group, which is a filter.
demo/records.json also carries a stand-in Dataverse: the account table, a
contact table and the primarycontactid lookup between them. So:
- an inline edit writes through and survives the next render;
- Primary contact edits through the platform's own route:
utils.lookupObjects, the relationship metadata, andwebAPI.updateRecordwith@odata.bind; - New asks
navigation.openFormfor a quick create form, and the harness answers with a stand-in form (pcfhub/pcfhub#64) whose save creates the row; - Category is a choice whose options the stand-in describes (pcfhub/pcfhub#52),
read through
utils.getEntityMetadata. So its cells edit through a dropdown, the filter row gives it one, and grouping by it groups on the choice. It stands where Status stood, because a form reportsstatecoderead-only and the control keeps it that way; - making an account a Preferred Customer is refused, by a fault in the
fixture (pcfhub/pcfhub#53), so
save()rejects and the rollback runs: the cell returns to Standard and names the reason under it.
Nothing leaves the browser.
What keeps it limited is what the harness cannot answer truthfully:
- Grouping always takes the browser route. The stand-in Dataverse refuses FetchXML.
openDatasetItemopens nothing. The event log names the record instead.
Each is named in demo.limitations.
There are five presets over the 24-row account fixture. Filtering and export shows 10 rows a page, so its pager has three pages. The others show 25, which is one page.
Download the managed solution from the latest release, or from the component's page on the hub, and import it into your environment.
npm install
npm start # the PCF test harness
npm run build
npm run lint
npm run check # what CI runs first: placeholders, pcfhub.json, control shape
npm run smoke # assertions against the built bundle, with an exit code
npm run preview # serves dev/preview.html — the control in a real browserRun npm run refreshTypes after every manifest edit — until you do,
context.parameters is typed from the old manifest and tsc will accept code that
cannot work.
To pack the solution locally you need msbuild — either Visual Studio or the Visual Studio Build Tools:
cd Solution
msbuild /t:build /restore /p:configuration=ReleaseBoth zips land in Solution/bin/Release. This is the only local step that compiles
in production mode, so a green npm run build is not evidence the shipping
bundle compiles — and the pack is incremental, so delete obj/, out/,
Solution/obj/ and Solution/bin/ first if you intend to quote a bundle size from
it.
- Bump the version in three places, in one commit — they are checked
against each other in CI:
DataTable/ControlManifest.Input.xml→<control version="…">Solution/src/Other/Solution.xml→<Version>package.json→"version"
- Tag it:
git tag v1.2.3 && git push --tags
The release workflow builds, packs both solution types, and attaches them to a GitHub Release. PCFHub picks the release up from its webhook within seconds, or from the hourly sweep otherwise. A sync imports a draft; a person publishes it.
| Path | What it is |
|---|---|
DataTable/ |
The control: manifest, entry point, CSS, localised strings |
Solution/ |
The Dataverse solution that packages it |
demo/ |
The dataset fixture the hub's demo harness runs against |
SPEC.md |
What building this corrected, and what is verified versus read |
docs/ |
The pages PCFHub publishes — see the comments in each file |
media/ |
Images and video referenced from the docs |
pcfhub.json |
The hub's manifest: identity, links, docs path, demo |
scripts/ |
Template setup and the CI guard that keeps it adopted |