Plugin Architecture: FormDataModel, View, and Controller

The demo2a_crud plugin separates its responsibilities across three QML files. Open each one in your editor as you read through this page.

1. FormDataModel — d2a_data_model.qml

The FormDataModel answers one question: what fields does the form have? It is a pragma Singleton, meaning there is exactly one instance shared across the whole plugin. It contains no logic and no runtime state — just a JS array of field descriptor objects.

pragma Singleton
import QtQuick

QtObject {
    property var fields: [
        { "name": "name", "label": "Entry Name" },
        { "name": "s1",   "label": "Thoughts"   },
        { "name": "s2",   "label": "Feelings"   }
    ]
}

Each object in the array has two keys:

To add a new field to the form, you only need to add one entry here. The rest of the plugin adapts automatically — that is the point of the pattern.

2. View — d2a_plugin_component.qml

The View answers: how does the form look, and how are user actions wired up? It owns the layout and creates a FormController instance. It never calls the QField API directly.

Creating the Controller instance

The View instantiates the Controller as a child object and exposes it via an alias so sub-components can reach it easily:

FormController {
    id: formController
    plotId: pluginFrame.plotId
    onSelect: function(f_uid) { /* sync ComboBox selection */ }
}
property var controller: formController

Reading the field list from FormDataModel

The View reads FormDataModel.fields once into a local property. This is the same array that the Repeater will iterate over:

property var fieldModel: FormDataModel.fields

The ComboBox — bound to controller.entryListModel

The entry selection dropdown is bound to controller.entryListModel, a ListModel that the Controller populates from the database. When the user makes a selection, the View calls controller.loadEntry(f_uid):

ComboBox {
    id: entrySelector
    model: controller.entryListModel
    textRole: "name"
    onCurrentIndexChanged: {
        if (currentIndex >= 0) {
            var f_uid = model.get(currentIndex).f_uid
            controller.loadEntry(f_uid)
        }
    }
}

The Repeater and the FormField delegate

Instead of writing one TextField per field by hand, the View uses a Repeater over fieldModel. The component FormField declaration is the delegate — the template the Repeater instantiates once per entry in the model array.

Each TextField inside FormField:

component FormField: Rectangle {
    property var fieldData

    RowLayout {
        Text {
            text: fieldData.label          // from FormDataModel
        }
        TextField {
            // READ: Controller is the source of truth
            text: controller.fieldValues[fieldData.name] !== undefined
                  ? controller.fieldValues[fieldData.name] : ""

            // WRITE: every keystroke updates the Controller
            onTextChanged: {
                controller.updateField(fieldData.name, text)
            }
        }
    }
}

Repeater {
    model: fieldModel          // FormDataModel.fields
    delegate: FormField {
        fieldData: modelData   // one field descriptor object per iteration
    }
}

Buttons

The three action buttons simply call Controller functions. The View does not know how those operations work:

// New Entry button
onClicked: { controller.createEntry() }

// Save Entry button
onClicked: { controller.saveEntry() }

The onSelect feedback loop

After createEntry() runs, the Controller rebuilds entryListModel and then emits the select(newUid) signal to tell the View which entry to highlight. The View catches this in the onSelect handler on the FormController instance and manually walks the model to find the right index:

FormController {
    id: formController
    plotId: pluginFrame.plotId
    onSelect: function(f_uid) {
        for (var i = 0; i < controller.entryListModel.count; i++) {
            if (controller.entryListModel.get(i).f_uid === f_uid) {
                entrySelector.currentIndex = i
                break
            }
        }
    }
}

Why can't a property binding do this automatically? Because the ComboBox's currentIndex is an imperative UI state, not a derived value. After the model is rebuilt the ComboBox resets to -1, so the Controller has to push the correct selection back up to the View through this signal. This is the one deliberate upward communication from Controller to View.

The inline component declaration

The component FormField: Rectangle { ... } syntax declares a locally-scoped component type available only inside this file. It is a QML 6 feature that avoids creating a separate FormField.qml file when the component is only used in one place. The Repeater references it by name as its delegate:

component FormField: Rectangle {
    property var fieldData
    // ... layout ...
}

Repeater {
    model: fieldModel
    delegate: FormField {
        fieldData: modelData
    }
}

modelData is the implicit variable provided by the Repeater for each item in the model array — here it is one field descriptor object from FormDataModel.fields.

onPlotIdChanged — why there are two triggers

The Controller receives plotId via a property binding (plotId: pluginFrame.plotId) and also has a Component.onCompleted guard. Yet the View independently calls controller.loadEntries(plotId) from its own onPlotIdChanged handler:

// In the View:
onPlotIdChanged: {
    if (plotId) {
        controller.loadEntries(plotId)
    }
}

// In the Controller:
Component.onCompleted: {
    if (plotId && layer) {
        loadEntries(plotId)
    }
}

The reason is timing. QML bindings are resolved after Component.onCompleted fires, so the Controller's plotId may still be empty when the component is first created. The View's onPlotIdChanged fires after the binding has delivered a real value, making it the reliable trigger. The Controller's Component.onCompleted is a fallback for cases where plotId is already set at construction time.

The debug status strip

Below the main status message there is a second line of diagnostic text that displays live internal state:

Text {
    text: "fields: "        + (fieldModel ? fieldModel.length : "null") +
          " | currentEntry: " + (controller.currentFuid
                                     ? controller.currentFuid.substring(0,8) : "none") +
          " | entries: "    + controller.entryListModel.count
}

This is intentional development scaffolding. Because these values are bound to live properties, the strip updates automatically whenever any of them changes, giving immediate feedback without opening a debugger. In a production plugin this strip would be removed or hidden.

The closed() signal and plugin teardown

The Close button emits closed() — a signal declared on pluginFrame. The View does not deactivate itself; it only announces that it wants to be closed:

// In the View:
signal closed()

Button {
    onClicked: { closed() }
}

The parent Loader in the main plugin file (demo2a_crud.qml) catches this with a Connections block and sets pluginLoader.active = false, which destroys the component and frees its resources. This is the standard QField plugin teardown pattern: the child signals intent, the parent acts.

3. Controller — d2a_form_controller.qml

The Controller answers: what values do the fields hold right now, and how are they persisted? It is the only component that calls the QField API (LayerUtils, FeatureUtils, layer.startEditing(), etc.).

The edit buffer: fieldValues

fieldValues is a plain JavaScript map. It is the runtime source of truth for every field in the form. When you read a feature from the database, its values are copied into this map. When you save, the values are read back out of this map and written to the layer.

property var fieldValues: ({})   // e.g. { "name": "Plot A", "s1": "...", "s2": "..." }

function updateField(fieldName, value) {
    fieldValues[fieldName] = value
    fieldValuesChanged()    // notify QML bindings in the View
}

The call to fieldValuesChanged() is important: because fieldValues is a JS object (not a QML property type), QML's binding engine cannot detect changes to its contents automatically. Calling the change signal manually forces the View's text bindings to re-evaluate.

The dropdown list: entryListModel

entryListModel is a ListModel. It is populated by querying the entries layer and appending one row per result. The ComboBox in the View is bound to this model.

property ListModel entryListModel: ListModel {}

function loadEntries(plotId) {
    entryListModel.clear()
    var expression = "plot_id = '" + plotId + "'"
    let it = LayerUtils.createFeatureIteratorFromExpression(layer, expression)
    while (it.hasNext()) {
        let feature = it.next()
        entryListModel.append({
            "name":  feature.attribute("name"),
            "f_uid": feature.attribute("f_uid")
        })
    }
    it.close()   // ALWAYS close the iterator
}

Loading an entry into the form

When the user selects an entry, loadEntry() fetches the feature and copies its attributes into fieldValues, iterating over FormDataModel.fields so that only the known fields are loaded — independent of what other columns the table may have:

function loadEntry(f_uid) {
    let it = LayerUtils.createFeatureIteratorFromExpression(
                 layer, "f_uid = '" + f_uid + "'")
    let feature = it.next()
    it.close()

    var map = {}
    for (var i = 0; i < FormDataModel.fields.length; i++) {
        var name = FormDataModel.fields[i].name
        map[name] = feature.attribute(name) || ""
    }
    fieldValues = map
    fieldValuesChanged()
    currentFuid = f_uid
}

Saving an entry

saveEntry() reads all values from fieldValues and writes them to the layer using changeAttributeValue. Note how fields.indexOf(key) is used to look up the attribute index by name — never a hard-coded number:

function saveEntry() {
    let it = LayerUtils.createFeatureIteratorFromExpression(
                 layer, "f_uid = '" + currentFuid + "'")
    let feature = it.next()
    it.close()

    layer.startEditing()
    var fid    = feature.id
    var fields = feature.fields

    for (var key in fieldValues) {
        var fieldIndex = fields.indexOf(key)
        if (fieldIndex >= 0) {
            layer.changeAttributeValue(fid, fieldIndex, fieldValues[key])
        }
    }
    layer.commitChanges()
}

Data Flow Summary