How Bonanza Replaced 20+ Checklist Forms with a Single PCF Control

Bonanza Drilling, an Alberta drilling contractor we work with, has more than twenty recurring checklist-style forms. Pre-job safety checks. Daily and periodic equipment walkdowns. Compliance audits. The kind of thing field crews tap through several times a shift, and the kind of thing an HSE lead, a maintenance manager, or a regulator can change the wording on without warning.

Each form was its own Dataverse table with its own purpose-built question columns. That worked, sort of, until somebody wanted to add a question. Or rename one. Or reorder a section. Or change a yes/no into yes/no/N-A. Then it was a ticket. A developer. A solution import. A test cycle. For one row’s worth of wording. Multiply that by twenty-plus forms and the IT backlog grows faster than it shrinks.

So we replaced the per-form code with one PCF control that reads its questionnaire out of the database.

What we shipped

1
Reusable Control
20+
Live Forms
All of them
Question Edits Without a Developer
5 days
Quoted Effort

One Power Apps Component Framework (PCF) control. The questionnaire (every question, its category, its response type, its options, its sort order) lives as rows in a Dataverse table. The control reads the rows filtered by which form it has been dropped onto, renders them grouped by section, and writes answers into a single shared answers table. Adding a question is a row insert. Reordering sections is a column edit. Switching free-text to a dropdown is changing two columns and saving.

The forms themselves stop carrying schema for the questionnaire. They carry the thing being inspected (the rig, the shift, the asset, the visit), and the control handles everything from “first question on the page” to “last answer saved.”

Topology diagram: a central blue-filled box labelled "checklist PCF control (one compiled component)" with arrows fanning out to six surrounding paper-form sketches, illustrating a single component bound to many host forms.

Why a PCF control inside a model-driven form

The host is still a model-driven form, the right default for any data-first Dataverse app, per Microsoft’s own decision guide. The PCF control sits inside it, bound to a placeholder column on the host table. We picked that combination because it keeps everything the model-driven host already does well, and adds the one capability it doesn’t.

Out of the box, model-driven forms do a lot for free. Role-based security, ribbon and command actions, audit history, related-record handling, the unified interface across web and mobile, view definitions, business process flows. You don’t reimplement any of that. The host form continues to be a Dataverse form for the parent record (the rig, the inspection, the visit), and gets all of that behaviour by default.

What model-driven forms don’t do natively is render rows from another table as inline, validated, save-as-you-go fields with the right input control per row. A “question” in a native model-driven form is a column on the host table. There is no built-in way to read a configurable list of questions from a separate table and render each one as the appropriate native input. You can show related records as a sub-grid, but a sub-grid is a list of pointers to other records, not an inline form that captures one answer per row.

A canvas app could render rows-as-inputs, but each canvas screen is a static design-time layout. You can loop a gallery over a questions table, but switching the input control type (checkbox vs yes/no toggle vs dropdown vs free text) per row at runtime is a tower of nested If/Switch expressions inside the gallery template. And you would lose the model-driven host: the security, ribbon, related-record context, audit, and unified interface that the inspection form already carries for free.

A PCF control gives us the missing piece without giving up the host. The control owns its own rendering: picking the right input type per row is a switch statement in TypeScript, not a maker-portal expression. It owns its own data flow: reading the questions table and writing the answers table happens inside the control. And because a PCF control binds to a placeholder column on the host form rather than to that form’s other columns, the same compiled component drops onto a rig safety form, a daily walkdown, and a pre-startup checklist without any of those forms knowing about the others.

That last property is the one that turns a working solution into a reusable one. Without PCF, “twenty forms share a checklist” means twenty form designs and at least twenty manual sync points. With PCF, it means twenty placeholders all pointing at the same control, and twenty model-driven forms that still feel like proper Dataverse forms to everyone using them.

The shape of the data

Two tables.

A questions table holds the questionnaire library. Each row is one question: which form it belongs to, which section it sits in, the wording, the response type (checkbox, yes/no, dropdown, free text), the options if it is a dropdown, and a default value if there should be one.

An answers table holds responses. Each row is one answer: a foreign key to the question, the captured value, and a soft link back to the parent record the form was filled out against.

That soft link is the move that makes the control reusable across twenty-plus forms instead of two or three.

Schema diagram: a questions table connects to a centred answers table by question_id, and the answers table soft-links via parent_table and parent_id columns to a dashed-outlined parent record box labelled "any dataverse table".

Polymorphic lookups are Dataverse’s official way of letting one column point to records in many different tables. They are constrained to entity sets you have to declare up front. Every time you want the control to attach to a new form, the answers table’s schema has to change to permit the new parent type, the schema has to be deployed, and any environments downstream of dev have to be updated in lockstep. You inherit that drag forever.

We sidestepped it by storing the parent as two text columns (the parent table’s logical name and the parent record’s ID) instead of a formal lookup. The answers table doesn’t know or care what its parent is. A new checklist form gets created tomorrow against a brand-new entity, and the same control drops onto it with no schema change anywhere in the answers chain. The trade-off is that referential integrity isn’t enforced by the platform. But since the control is the only writer, and it gets the parent context from the form it is hosted on, the integrity holds in practice.

Why data-driven response types

The control supports four response types out of the box: a checkbox, a yes/no toggle, a dropdown with a comma-separated options list, and free text. Choosing which one a question uses is a column value on the question row. No code, no deploy.

The payoff shows up the first time someone says “we need this question to also accept N/A.” In the static-column world, that is a developer ticket: change the option set, redeploy, retest. In the data-driven world, it is editing two cells. The form owner (the HSE lead, the analyst, the operations manager who actually owns the wording) can do it themselves.

We also use a small convention for section ordering: section names are prefixed with a number (1. Site Conditions, 2. Equipment, 3. Sign-Off). The control parses the prefix and sorts on it. No separate sort column to maintain, no reordering UI to build, and the section names themselves become self-documenting in the table view that content authors actually use.

The rendering loop

The interesting part is small. The control loads the questions and the existing answers, joins them on the question ID, groups by category, and renders a section per category. The whole grouping pass is twenty lines:

function buildSections(questions, answers) {
    const answerMap = new Map(answers.map(a => [a.questionId, a]));

    const items = questions.map(q => ({
        question: q,
        answer: answerMap.get(q.id),
        currentValue:
            answerMap.get(q.id)?.answerValue ?? q.defaultValue ?? "",
    }));

    const byCategory = new Map();
    for (const item of items) {
        const cat = item.question.category || "General";
        if (!byCategory.has(cat)) byCategory.set(cat, []);
        byCategory.get(cat).push(item);
    }

    return [...byCategory.entries()]
        .map(([category, items]) => ({ category, items }))
        .sort(
            (a, b) =>
                parseCategoryPrefix(a.category) -
                parseCategoryPrefix(b.category),
        );
}

That is the entire architecture. The pile of forms it replaces is not replaced by a pile of code; it is replaced by a small read-and-group function and a couple of well-shaped tables.

What changes for the people who use it

The form owner (usually someone on the HSE or operations side, not IT) opens the questions table and edits questions like spreadsheet rows. Adds, edits, deletes. Reorders. Changes a response type. The change is live the next time someone opens the form. There is no deployment lane between the change and the field crew using it.

The field crew sees a faster, more consistent experience across forms. Every checklist looks and behaves the same way: section by section, question by question, with the right input control for each response type. Answers save as they go, not on submit, so a dropped tablet at the end of a long shift does not lose a half-finished inspection.

The IT team stops being the bottleneck for content changes that were never actually IT problems.

Where this pattern fits

Anywhere a recurring process is currently captured by a static-column form. Field inspections and audits are the obvious cases. That is where we built this first, for Bonanza Drilling, an oil and gas client running across Edmonton and Calgary operations. But the same shape works for intake questionnaires, compliance attestations, training sign-offs, post-incident reviews, and equipment commissioning checklists, anywhere the questionnaire changes faster than IT can keep up.

The signal that you have this problem: someone in your organization is asking IT to “add a question” and waiting weeks for it. The fix is not a faster IT team. It is a form that does not need them.

Have a static form that should be data-driven?