vela-client
v0.1.6
Published
Typed, testable, form-centric client scripting runtime and test harness for Dataverse model-driven forms.
Readme
Typed, testable, form-centric TypeScript for Dataverse model-driven client scripting - the browser-side half of Vela. It gives form scripts the same strongly-typed, unit-testable "firm ground" that Vela brings to server-side plugins: per-form typed models, a class-based API faithful to the Client API, and a fake-Xrm test harness so scripts are tested without a browser.
Status
Approaching the 0.1.0 vertical slice (generate -> write -> test). The form-scripting runtime ships:
VelaForm(Model), the typed guaranteed-present this.fields / this.controls / this.tabs accessors,
lifecycle dispatch (onLoad / onSave), and the FormModel contract type with its CONTRACT_VERSION
stamp. The fake-Xrm test harness ships too, as the vela-client/testing subpath, so form scripts are
unit-tested off-browser against the real dispatch. The typed Web API layer ships as the
vela-client/webapi subpath: a typed query/CRUD builder and typed action/function clients over the
native Xrm.WebApi, loaded only when a script queries (kept out of the base form-scripting bundle).
Still on the roadmap: the form-model generator (vela models --forms, which lives in the .NET
Vela repo) and integrated deploy plus FormXML event wiring. See
ROADMAP.md for the full phased plan.
Install
npm install vela-clientThe fake-Xrm test harness is not a separate package - it ships as the vela-client/testing subpath of
this same package.
How it works today
The VelaForm runtime and the fakeForm harness below both ship now, so a form script and its unit test
are exactly what you write today. The one piece still on the roadmap is the generator that emits the form
model, so for now you hand-write (or stub) the model const yourself against the FormModel contract type.
A form model
One const per form. Today you hand-write or stub this; once the generator ships (vela models --forms)
it is emitted per form from FormXML plus metadata. Only the fields, tabs, sections, and subgrids actually
on that form appear; each field carries its kind (which drives the typed accessor), its base-language
label, its form-level required, and its place in the tab/section tree.
// generated/contact.information.form.ts - the generator that emits this is on the roadmap
import type { FormModel } from 'vela-client';
import { contact_gendercode, contact_statuscode, contact_statecode } from './option-sets';
export const ContactInformation = {
entity: 'contact',
formId: '7c1e3f2a-0000-0000-0000-000000000001',
formType: 'Main',
fields: {
firstname: { kind: 'string', label: 'First Name', required: 'none', at: { tab: 'general', section: 'contactInfo' } },
lastname: { kind: 'string', label: 'Last Name', required: 'required', at: { tab: 'general', section: 'contactInfo' } },
emailaddress1: { kind: 'string', label: 'Email', required: 'none', at: { tab: 'general', section: 'contactInfo' } },
birthdate: { kind: 'date', label: 'Birthday', required: 'none', at: { tab: 'general', section: 'contactInfo' } },
creditlimit: { kind: 'money', label: 'Credit Limit', required: 'none', at: { tab: 'details', section: 'finance' } },
gendercode: { kind: 'choice', label: 'Gender', required: 'none', options: contact_gendercode, at: { tab: 'general', section: 'contactInfo' } },
statuscode: { kind: 'choice', label: 'Status', required: 'required', options: contact_statuscode, at: { tab: 'general', section: 'contactInfo' } },
donotemail: { kind: 'boolean', label: 'Do Not Email', required: 'none', at: { tab: 'general', section: 'contactInfo' } },
parentcustomerid: { kind: 'lookup', label: 'Company', required: 'none', targets: ['account', 'contact'], at: { tab: 'general', section: 'contactInfo' } },
},
layout: {
tabs: {
general: { label: 'General', sections: { contactInfo: { label: 'Contact', fields: ['firstname', 'lastname', 'emailaddress1', 'birthdate', 'gendercode', 'statuscode', 'donotemail', 'parentcustomerid'] } } },
details: { label: 'Details', sections: { finance: { label: 'Finance', fields: ['creditlimit'] } } },
},
},
subgrids: { Opportunities: { entity: 'opportunity', relationship: 'opportunity_parent_contact' } },
} as const satisfies FormModel;A form script
A form script is a class extends VelaForm(Model) with lifecycle methods. Because the model comes from the
form, this.fields.x / this.controls.x are guaranteed to exist - only .getValue() may be null. Method
names stay faithful to the Client API, and onSave receives a typed SaveEventArgs. The last line wires the
class to the global onLoad / onSave names FormXML binds to - one defineForm call per form web resource.
// forms/ContactInformationForm.ts
import { VelaForm, defineForm, SaveEventArgs } from 'vela-client';
import { ContactInformation } from '../generated/contact.information.form';
import { contact_gendercode } from '../generated/option-sets';
export class ContactForm extends VelaForm(ContactInformation) {
onLoad() {
this.fields.gendercode.addOnChange(this.applyGenderRules); // real Client API name, auto-bound
this.applyGenderRules();
}
applyGenderRules() {
const female = this.fields.gendercode.getValue() === contact_gendercode.Female;
this.tabs.details.setVisible(female);
this.fields.lastname.setRequiredLevel(female ? 'none' : 'required');
this.controls.creditlimit.setVisible(!female); // control (UI), not attribute
}
async onSave(e: SaveEventArgs) {
if (!this.fields.emailaddress1.getValue()) {
e.preventDefault();
this.setFormNotification('Email is required', 'ERROR'); // real ui.setFormNotification
}
}
}
// FormXML binds these global names; one defineForm call is the whole wiring.
export const { onLoad, onSave } = defineForm(ContactInformation, ContactForm);A unit test
The harness reuses the same typed field API as the runtime and runs the real dispatch off-browser. The only
harness-specific verbs are fakeForm(Model, FormClass), form.load(), and form.triggerSave().
// tests/ContactInformationForm.test.ts
import { fakeForm } from 'vela-client/testing';
import { ContactInformation } from '../generated/contact.information.form';
import { contact_gendercode } from '../generated/option-sets';
import { ContactForm } from '../forms/ContactInformationForm';
test('gender rules toggle the details tab and the last-name requirement', async () => {
const form = fakeForm(ContactInformation, ContactForm);
await form.load();
form.fields.gendercode.setValue(contact_gendercode.Female);
form.fields.gendercode.fireOnChange(); // real Client API - triggers the attached handler
expect(form.tabs.details.getVisible()).toBe(true);
expect(form.fields.lastname.getRequiredLevel()).toBe('none');
});A typed query
The vela-client/webapi subpath is a typed layer over the native Xrm.WebApi. Column names are typed keys,
$select narrows the result, and $filter is a typed builder. See
docs/guides/client-queries.md.
import { createWebApi } from 'vela-client/webapi';
const { entities } = await createWebApi() // no argument: binds to the ambient Xrm.WebApi
.retrieveMultiple<Account>('account')
.select('name', 'revenue')
.filter((f) => f.and(f.eq('statecode', 0), f.gt('revenue', 1000)))
.orderBy('name')
.top(20)
.execute(); // entities: Pick<Account, 'name' | 'revenue'>[]Design principles
- Model the form, not the table. At runtime the Client API only sees the columns and controls on the loaded form, so a typed model is generated per form, not per table.
- Guaranteed-present accessors. Because the model comes from the form,
this.fields.x/this.controls.xare guaranteed to exist; only.getValue()may be null. OnlytryField('offModel')returns nullable. - Names faithful to the Client API. No renaming of
getValue/setVisible/addOnChangeand friends, so experienced Dataverse developers are immediately at home. The attribute (data) vs control (UI) split is kept exactly as the Client API has it. - Own our Client API typings. No
@types/xrm; only the small, stable surface we wrap is typed, which bounds the maintenance cost. Everything else stays reachable through the escape hatch. - Tiny runtime. The library loads on every form open, so the core stays aggressively tree-shakeable, and the Web API query helper is lazy-loaded rather than pulled into the base form-scripting path.
- A raw escape hatch is always available.
this.form(rawformContext) andthis.executionContext(rawExecutionContext) are always there, so anything Vela has not wrapped can still be done with the real Client API.
Two-repo layout
This repository is the TypeScript runtime plus the fake-Xrm harness. The C# CLI that generates the typed
form models (vela models --forms) and deploys the web resources (vela apply) lives in the main Vela
repo at https://github.com/allandecastro/vela. The two are linked by a versioned contract fixture - the
exact shape of the generated .ts - stamped with CONTRACT_VERSION and checked at runtime, so the
generator and the library can never silently desync. See AGENTS.md for how the pieces fit
together and docs/design-examples.md for the concrete target shapes.
Development
npm run build # bundle with tsup (ESM, per-entry chunks)
npm run typecheck # tsc --noEmit (strict)
npm run test # Vitest
npm run test:coverage # Vitest with v8 coverage thresholds
npm run size # size-limit budget for each entry point
npm run mutation # Stryker-JS mutation testing (form, runtime, webapi)Quality gates run in CI: coverage thresholds, per-entry bundle-size budgets, and (on changes to the correctness code) Stryker-JS mutation testing. All TypeScript jobs run on hosted Linux.
License
MIT
