The demo2a_crud plugin separates its responsibilities across three QML files. Open each one in your editor as you read through this page.
d2a_data_model.qml — FormDataModel (Singleton)d2a_plugin_component.qml — Viewd2a_form_controller.qml — ControllerThe 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:
name — the attribute name in the GeoPackage layer. Used by the Controller to read and write feature attributes.label — the human-readable text displayed next to the input in the form.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.
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.
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
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 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)
}
}
}
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:
controller.fieldValues[fieldData.name] — a property binding that updates automatically when the Controller signals a change.controller.updateField(fieldData.name, text) on every keystroke.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
}
}
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() }
onSelect feedback loopAfter 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.
component declarationThe 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 triggersThe 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.
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.
closed() signal and plugin teardownThe 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.
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.).
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.
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
}
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
}
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()
}
controller.plotId → Controller calls loadEntries() → entryListModel is populated → ComboBox updates automatically via binding.onCurrentIndexChanged → View calls controller.loadEntry(f_uid) → Controller fetches feature and fills fieldValues → TextFields update automatically via binding.onTextChanged → controller.updateField(name, text) → fieldValues updated in-memory. No database write yet.controller.saveEntry() → Controller reads fieldValues, calls QField API, commits changes to layer.controller.createEntry() → Controller writes a new feature, refreshes entryListModel, selects the new entry.