Dataverse Form Authoring — Gotchas

TL;DR: You can build a model-driven form (tabs, sections, and fields) entirely via the Web API. Two things bite you: (1) a field control’s datafieldname must be the lowercase logical name (psi_cabinet), not the PascalCase SchemaName (psi_Cabinet) — get this wrong and the field silently fails to render; and (2) after editing form XML you must run PublishAllXml — publishing just the entity does not flush the Unified Interface’s form cache, so the form looks blank until a full publish (+ a short propagation delay).

What happened (2026-07-08)

Building the Phase-1 Proposal Request questionnaire (psi_proposalrequest, PSIExtensions solution) we created everything programmatically via the Dataverse Web API + Python SDK:

  • Table + ~74 columns (choice / text / memo / money / date / bool) — worked.
  • N:1 relationship to Opportunity — worked (see relationship-name note below).
  • A list view via POST /savedqueries — worked.
  • A main form via POST /systemforms with hand-authored formxml — the 7 section tabs rendered, but every field was blank.

Root cause: datafieldname casing

The field controls used the PascalCase SchemaName in datafieldname (e.g. psi_Cabinet, psi_CustomerRFQDate). The modern Unified Interface binds form field controls by the lowercase logical name (psi_cabinet, psi_customerrfqdate). With the wrong casing the controls don’t bind and render as nothing — no error.

How we confirmed it: open the form in the maker-portal form designer. With PascalCase datafieldname, the designer showed every column as “unused” and all sections empty (it couldn’t bind our controls). After patching the formxml to lowercase logical names, the designer immediately showed all the fields laid out correctly. The designer is a great oracle here — if it shows your API-authored fields, the definition is right.

The designer’s own control markup, for reference:

<cell id="{GUID}" locklevel="0" colspan="1" rowspan="1">
  <labels><label description="Automation Level" languagecode="1033" /></labels>
  <control id="psi_automationlevel" classid="{3EF39988-22BB-4F0B-BBBE-64B5A3748AEE}"
           datafieldname="psi_automationlevel" disabled="false" />
</cell>

Key: id and datafieldname are the lowercase logical name. (The colspan/rowspan/locklevel and disabled="false" match the designer but the casing is the part that determines rendering.)

The other half: publish the whole org, not just the entity

Even with correct casing, the runtime stayed blank after PublishXml on the entity. The Unified Interface caches form metadata at the app level; a single-entity publish did not flush it. POST /PublishAllXml (body {}) + a short wait (~30 s) flushed the cache and the form rendered all fields (text, lookup, choice, date, memo). A fresh browser tab alone was not enough — it was a server-side published-metadata cache, not a browser cache.

formpresentation also matters: POST /systemforms defaults to 0 (Classic); set it to 1 (Unified Interface). (Necessary but not sufficient on its own.)

Two adjacent gotchas found the same day

  • Relationship schema name needs the publisher prefix. create_lookup_field(...) auto-generates referenced_referencing_lookup (e.g. opportunity_psi_proposalrequest_psi_OpportunityId), which Dataverse rejects — a solution component’s name must start with the publisher prefix. Use create_one_to_many_relationship(...) with an explicit psi_-prefixed schema_name (e.g. psi_opportunity_proposalrequest).
  • “Related” menu needs the referenced entity published. After adding a N:1 lookup to an existing table (e.g. Opportunity), the child doesn’t appear under the parent’s Related menu until you PublishXml the referenced entity (the associated menu was already set to display).

What Dataverse can and cannot scope (read this before saying “make it different in app X”)

The single most expensive misunderstanding in this environment. Dataverse is a shared-schema database first — tables, columns, forms, views and their properties are environment-level objects, dating to the 2003 CRM data model. Model-driven apps arrived later as a filter over that schema, not an isolation boundary. So there is no “set it this way in the Service app and that way in the hub.”

ScopeWhat it actually controls
Security roleForm visibility (<Role Id> inside systemform.formxml DisplayConditions); table/view access via privileges. This is the real per-audience mechanism.
App moduleWhich components exist in an app (AppModuleComponent), plus navigation (sitemap).
URL parametersPer-link behavior — initial view, specific form, prefilled fields. The escape hatch when you need per-app behavior.
Environment (everything else)IsQuickCreateEnabled, savedquery.isdefault, quick-create/main form order, RequiredLevel, column default values, correlation settings.

The rule: Dataverse scopes by who (role) and what’s included (app module) — never by where (which app or client). If you are reaching for a property toggle to get per-app behavior, stop: the answer is role scoping, or a parameterized URL.

Concrete traps this has caused

  • There is NO client-based form scoping. A form named App for Outlook Case Quick Create is named by convention only — nothing binds it to Outlook. Form selection is order + security role

    • app-module inclusion. That is exactly why Microsoft’s own deploy doc has to tell you to reorder the form rather than just assign it to the Outlook client.
  • IsQuickCreateEnabled is table-wide. Enabling it so App for Outlook can offer + > Case also switched the web app’s ”+ New Case” from the full main form to a quick-create flyout, for every user. Foreseeable, and it surprised the business mid-session.

  • Quick-create form order is global. Promoting one form to Order 1 changes it everywhere. When PSI needed prefill from an Outlook item and the PSI-specific fields, the fix was NOT reordering — it was moving the prefill onload handler (PrepopulateForms.PrepopulateCaseForm.prepopulate, library new_MailAppScriptResource) onto PSI’s own form, plus adding the rep roles to that form’s DisplayConditions.

  • A sitemap SubArea has no view attribute — but a Url pins the view per app. Do NOT flip savedquery.isdefault to change a nav item’s landing view; that is environment-wide and will move the list in every other app. Instead:

    <SubArea Id="subarea_queues" Icon="..."
             Url="/main.aspx?pagetype=entitylist&amp;etn=queueitem&amp;viewid=%7b<view guid>%7d&amp;viewType=1039"
             Client="All,Web" AvailableOffline="true" PassParams="false" Sku="All,OnPremise,Live,SPLA">
      <Titles><Title LCID="1033" Title="Queues" /></Titles>
      <Descriptions><Description LCID="1033" Description="..." /></Descriptions>
    </SubArea>

    viewType=1039 = system view (savedquery); 4230 = personal (userquery). <Titles> and <Descriptions> plus an icon are required when using Url instead of Entity, because the platform can no longer infer them from the table. Caveat: the view selector still shows, and the user’s last manual choice is remembered on the next visit.

  • Role-scoped forms hide silently. Progressive Surface Case Quick Create was gated to System Administrator + System Customizer, so Customer Service Representatives silently fell through to the plain out-of-box form. No error, no warning — just the wrong form. Always check DisplayConditions roles against the roles your users actually hold.

Diagnosing “why don’t I see my change?”

  1. pac solution import --publish-changes runs Publish All Customizations — good. A bare entity-scoped PublishXml does not flush form/app metadata cache. Use PublishAllXml.
  2. Hard-reload (Ctrl+Shift+R); a new tab is not always enough. Sign out/in for sitemap changes.
  3. Confirm you are in the right app — the managed multisession Progressive Surface Service Workspace has a completely separate sitemap from PSI Service (psi_PSISiteVisits).
  4. Check the user’s privileges on the entity behind the nav item.

The reliable pattern

  1. Schema via API / SDK — tables, columns, choices, relationships, and views (savedqueries).
  2. Form via APIPOST/PATCH /systemforms with formxml; set formpresentation=1; use lowercase logical names for control id and datafieldname.
  3. POST /PublishAllXml (not just the entity) and wait ~30 s.
  4. Verify in the maker designer (shows fields if the definition is right) and at runtime.
  5. Pull to repopac solution export + unpack, commit.

The maker-portal designer remains the easy path for form layout (drag-drop), and its Save & Publish always produces valid, renderable XML — but the API route works fine once the casing and PublishAllXml rules above are respected.

See also

  • Schema change policy
  • Repo: d365-ce-migration/scripts/ (build_proposalrequest.py, build_pr_form.py, add_lookup.py, fix_related_menu.py, publish_all.py) and d365-ce-migration/docs/proposal-request-poc.md