Skip to content

Bind a record to a form

Wire a ModelRecord to a set of form components, with explicit commit / reject and dirty-state visualisation.

Goal

Edit a person record across TextField, Checkbox, and ComboBox inputs. The save button stays disabled until the user makes a change; the cancel button reverts unsaved edits.

Set up the record

typescript
import { Binding, Event } from '@jimka/typescript-ui/core';
import { Model, MemoryStore } from '@jimka/typescript-ui/data';
const PersonModel = new Model([
    { name: 'name',   type: 'string'  },
    { name: 'email',  type: 'string'  },
    { name: 'role',   type: 'string'  },
    { name: 'active', type: 'boolean' },
]);

const store = new MemoryStore(PersonModel, [
    { name: 'Alice', email: 'alice@example.com', role: 'admin', active: true },
]);
await store.load();

Build the form

typescript
import { Component } from '@jimka/typescript-ui/core';
import { VBox, HBox } from '@jimka/typescript-ui/layout';
import { Label, TextField, Checkbox, ComboBox, Option } from '@jimka/typescript-ui/component/input';
import { Button } from '@jimka/typescript-ui/component/button';
const nameField   = TextField();
const emailField  = TextField();
const activeCheck = Checkbox();
const roleCombo   = ComboBox()
    .addItem(Option('admin', 'Admin'))
    .addItem(Option('user',  'User'))
    .addItem(Option('guest', 'Guest'));

const form = Component({
    layoutManager: VBox(),
    components: [
        Label('Name',   nameField.getId()),   nameField,
        Label('Email',  emailField.getId()),  emailField,
        Label('Role',   roleCombo.getId()),   roleCombo,
        Label('Active', activeCheck.getId()), activeCheck
    ]
});

The fields are bound to named references because the Binding below needs handles on them; everything else (labels, layout managers) is declared inline.

Bind and react to dirty state

typescript
const binding = new Binding()
    .bind('name',   nameField)
    .bind('email',  emailField)
    .bind('role',   roleCombo)
    .bind('active', activeCheck);

binding.setRecord(store.getAt(0));

const saveBtn   = Button('Save');
const cancelBtn = Button('Cancel');
saveBtn.setEnabled(false);
cancelBtn.setEnabled(false);

binding.addChangeListener(() => {
    const dirty = binding.getRecord()?.isDirty() ?? false;
    saveBtn.setEnabled(dirty);
    cancelBtn.setEnabled(dirty);
});

Event.addListener(saveBtn,   'click', () => binding.commit());
Event.addListener(cancelBtn, 'click', () => binding.reject());

form.addComponent(Component({
    layoutManager: HBox(),
    components: [saveBtn, cancelBtn]
}));

Switching records

Calling setRecord with a different record discards uncommitted edits — there is no built-in "save first?" prompt. Wrap the call site if you need confirmation:

typescript
async function selectRecord(rec: ModelRecord) {
    if (binding.getRecord()?.isDirty()) {
        const result = await Dialog.show({
            title:   'Unsaved changes',
            message: 'Discard your edits?',
            buttons: [
                { text: 'Discard', result: 'confirm', primary: true },
                { text: 'Cancel',  result: 'cancel'  },
            ],
        });
        if (result !== 'confirm') return;
    }
    binding.setRecord(rec);
}

See also

  • Binding — full API surface and explicit-accessor patterns
  • Record — dirty / commit / reject lifecycle
  • CRUD with a Table — pairs naturally with this for master / detail UIs