Actions, triggers and client extensions
Choose the smallest safe extension mechanism: form JavaScript, restricted scripts, or a compiled module.
Script extensions
Script extensions lower the entry cost for straightforward business rules. They supplement, rather than replace, compiled FormPlatform modules.
| Extension type | Location | Execution environment | Intended use |
|---|---|---|---|
| Form Action Code | A form's metadata `actionCode` | Browser, as an ES module | Client interaction, display state and calling browser APIs |
| Server Action Script | `App_Data/script-extensions/*.js` | Server-side Jint JavaScript interpreter | Transform submitted form data and return notifications/state |
| Trigger Script | `App_Data/script-extensions/*.js` | Server-side Jint JavaScript interpreter | Validate or modify the current entity batch in a persistence lifecycle |
| Compiled module | A module DLL | Full trusted .NET host | Database/API integrations, long-running work and privileged services |
Enable the feature
It is disabled by default. Add the following configuration using `appsettings.Production.json`, environment variables, systemd credentials, or another normal configuration source. Do not place the scripts in `wwwroot`.
"ScriptExtensions": {
"Enabled": true,
"Directory": "App_Data/script-extensions",
"TimeoutMilliseconds": 250,
"MaxStatements": 10000,
"MaxRecursionDepth": 64,
"MaxMemoryBytes": 8388608,
"MaxScriptSizeBytes": 65536,
"MaxResultSizeBytes": 262144
}
Copy the two files in `deploy/script-extensions.example` into the configured directory, then restart FormPlatform. Script manifests are read once at startup so action registration is deterministic. A missing directory is harmless; an invalid manifest, duplicate action name, missing script file, or action-name collision with a compiled provider stops startup with a clear error.
Each `*.script.json` manifest declares which names it owns:
{
"name": "CustomerRules",
"scriptFile": "customer-rules.js",
"formActions": ["NormalizeCustomer"],
"triggerActions": ["ValidateCustomer"],
"enabled": true
}
Action names must start with a letter and otherwise contain only letters, numbers, `.`, `_`, or `-`. Names are global across built-in actions, script files and compiled modules.
The JavaScript file is a single object expression. It may contain a `formActions` object, a `triggers` object, or both. Handler keys must match the manifest exactly. Handlers are synchronous; promises are intentionally rejected.
({
formActions: {
NormalizeCustomer(input) {
return { data: { ...input.data, fullName: input.data.fullName.trim() } };
}
},
triggers: {
ValidateCustomer(input) {
const errors = input.entities[0].fullName ? [] : [
{ attribute: 'fullName', message: 'Full name is required.' }
];
return { entities: input.entities.map(() => ({})), validation: errors };
}
}
})
Configure an action in a form
No new designer control is needed. Existing mapping editors call `/api/admin/form-mappings/actions`; registered script action names therefore appear with the existing server actions.
For a **Server Action Script**, set the form mapping to:
Target kind: ServerAction
Action: NormalizeCustomer
Options: { "source": "customer-form" }
The handler receives:
{
form: { id, name, displayName },
user: { id }, // null for an anonymous caller
data: { /* submitted form JSON */ },
options: { /* mapping Options JSON */ }
}
It returns an object. `data` must be an object and becomes the server action result; omit it to return the original submitted data. `notifications` and `state` are optional browser effects.
{
data: { /* result form JSON */ },
notifications: [
{ message: 'Saved.', type: 'success', title: 'Customer', durationMs: 4000 }
],
state: { selectedCustomerId: '...' }
}
For a **Trigger Script**, add a trigger using the existing Form and Data Mapping lifecycle editor:
Events: Validate, BeforeInsert
Action: ValidateCustomer
Options: { "minimumLength": 2 }
Its input is:
{
operation: 'Insert',
user: { id },
entity: { name, table },
entities: [ { /* current attribute values */ } ],
options: { /* trigger Options JSON */ }
}
Trigger results may contain:
{
// Optional. If present, exactly one object per input entity is required.
// Only listed writable, non-key, non-generated, non-concurrency attributes change.
entities: [ { updatedBy: '...', updatedDate: '2026-08-25T00:00:00.000Z' } ],
// Optional. Each attribute must be in the target Data Model.
validation: [ { attribute: 'fullName', message: 'Full name is required.' } ],
// Optional. Stops the lifecycle without persisting changes.
terminate: false,
message: 'Optional user-facing lifecycle message'
}
Unlike a form action, the current trigger pipeline exposes its message as the existing lifecycle notification. Trigger scripts do not return arbitrary client code or direct database access. Use a server action or compiled module when the rule must call another API, query unrelated data, enqueue work, or use DI.
Security boundary
The host creates a fresh Jint engine for every call. It passes only serialized JSON strings and never calls Jint `AllowClr`. Scripts receive no .NET service, database connection, `HttpClient`, filesystem object, reflection API, secret, or authenticated cookie. Runtime limits cap time, statements, recursion, source size and result size.
This is a **capability-restricted extension surface**, not a hostile-code security boundary. Runtime limits reduce accidental runaway code but cannot be treated as a complete denial-of-service defense against deliberately malicious JavaScript. Anyone who can edit `App_Data/script-extensions` already has deployment-level filesystem access and must be trusted. Do not accept arbitrary tenant JavaScript as a safe multi-tenant feature. For hostile/untrusted code, use a separately isolated worker/container with a narrow HTTP/RPC contract.
Form Action Code remains browser code: it is visible to form viewers and must never contain secrets or be trusted for authorization. Enforce authorization, validation and persistence rules in server actions, triggers, or compiled modules.
When to use which option
- Use Form Action Code for UI-only behavior, such as changing control visibility or loading browser-side data.
- Use a script trigger for small synchronous rules on the entity that is already being saved.
- Use a script server action for a small data transform or a server-generated notification.
- Use a compiled module whenever the code needs database queries beyond the current save, outbound HTTP, email, files, queues, background work, reusable APIs, or a strong security boundary.
---
FormPlatform Client Component Contracts
中文:COMPONENT_CONTRACTS.zh-CN.md
A component contract is the stable boundary between Designer, Preview, Viewer, Form Runtime, and third-party controls. It complements Vue `defineProps`: the contract records whether a control produces form data, its value and event semantics, validation participation, mode behavior, and serialization ownership.
1. Single source of truth
Built-in contracts live in `ClientApp/src/components/componentContracts.js`. Their keys must exactly match `formComponentRegistry.js`; Vitest rejects missing or orphaned contracts. Form Runtime uses the contract to identify data components, and the Events tab derives built-in event availability from the same source.
Each contract declares `category`, `dataComponent`, public `props` and `emits`, configurable `events`, `value`, `validation`, Designer/Preview/Viewer `modes`, and `serialization`. Categories are data, action, navigation, host, container, collection, or display. Validation is none, field, or page-and-form.
The built-in catalog covers nine data controls (Input through Calendar plus GeoLocation), Button/Menu/Breadcrumb/SystemSlot, eight containers, DataGrid/ItemRenderer/Spreadsheet collections, and seven display controls. The source contract lists the exact type names and remains authoritative.
2. Value rules
- Normal data controls use `value` / `update:value` and map through `other.propertyName`.
- Checkbox uses `checked` / `update:checked`; checked submits its configured value, empty or false configuration means `true`, and unchecked submits `null`.
- Radio compares database booleans with string options `"true"` and `"false"`; deselecting the active option returns `null`.
- GeoLocation is a read-only browser/device measurement. It submits either a JSON location snapshot (default) or `latitude,longitude`; it never overwrites a saved nonempty value automatically when an existing record is opened.
- Hidden fields are neither validated nor included among visible submitted controls.
- DataGrid and ItemRenderer records are external collections, not main-form values.
These rules are centralized in `componentValueModel.js` and shared by the renderer, table, designer, and controls.
3. Runtime modes
Designer must not produce business side effects; containers are dropzones, DataGrid uses mock data, and host slots are placeholders. Preview runs UI rules and validation under preview persistence/API policy. Viewer enables full actions, APIs, triggers, and submission. Contract mode values document and test this behavior while Vue implementations remain responsible for enforcing it.
4. Third-party control example
api.registerControl({
type: 'rating',
component: () => import('./RatingControl.vue'),
loader: true,
dataComponent: true,
events: ['onChange', 'onFocus', 'onBlur'],
contract: {
category: 'data',
dataComponent: true,
props: ['tableId', 'designerMode', 'value', 'readOnly', 'validationClass'],
emits: ['update:value'],
events: ['onChange', 'onFocus', 'onBlur'],
value: { prop: 'value', emit: 'update:value', acceptedTypes: ['number', 'null'], emptyValue: null },
validation: 'field',
modes: { designer: 'preview', preview: 'runtime', viewer: 'runtime' },
serialization: { runtimeValue: 'form-data-by-propertyName' }
}
})
The platform derives a compatibility contract for an old extension without `contract`, but new modules must declare one explicitly.
5. Tests and change checklist
cd src/FormPlatform.Host/ClientApp
npm ci
npm run test:unit
Vitest covers substitutions, stable/temporary action contexts, required/custom validation, cache/debounce, pagination rules, Checkbox/Radio null and boolean behavior, DataGrid models, and exhaustive built-in contracts.
For every control change, update and verify registry/contract, Vue props/emits, Events tab, value/null/boolean/multiple serialization, validation and hidden/read-only behavior, all three modes, substitution/i18n, and unit tests. Add Playwright for complex interaction. `Frontend unit tests and component contracts` is a required CI gate.