FForm Platform
enzh-CN

Developer guide

A practical path from a first editable form to advanced server and client extensions.

FormPlatform Beginner, Intermediate, and Advanced Developer Guide

Before creating or changing a form control, also read Client Component Contracts.

中文:DEVELOPER_GUIDE.zh-CN.md

The levels describe capability, not job title. A beginner can deliver a feature with the Designer and existing Data Models. An intermediate developer extends services and controls. An advanced developer maintains platform boundaries, performance, security, migrations, and release compatibility.

1. Foundation for every developer

Read System Architecture first. Form definition, form metadata, management data, and business records are different domains. Client visibility is not authorization. Fixed DDL is not initializer work. Third-party modules never reference the Host assembly.

Recommended tools are .NET 10 SDK, Node.js 22, PostgreSQL 17 (MySQL and SQL Server are supported), Git, and browser developer tools. Docker is required for PostgreSQL Testcontainers tests.

Use User Secrets for local credentials:

dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "ManagementDatabase:ConnectionString" "Host=localhost;Database=formplatform;Username=postgres;Password=..."
dotnet user-secrets --project src/FormPlatform.Host/FormPlatform.csproj set "FormStorage:ConnectionString" "Host=localhost;Database=formplatform;Username=postgres;Password=..."

Never commit real passwords to `appsettings.json`.

2. Beginner: deliver business features with forms

2.1 Outcomes

You should be able to start both applications, understand when a rebuild is needed, create Data Models and mappings, use the major controls, configure validation/conditions/events/substitutions, diagnose browser/server errors, and deliver CRUD without Host code changes.

2.2 Running locally

# terminal 1
dotnet run --project src/FormPlatform.Host/FormPlatform.csproj --urls http://localhost:5080

# terminal 2
cd src/FormPlatform.Host/ClientApp
npm run dev

Open Vite on `http://localhost:5173`. Database-only form edits need no npm or .NET rebuild. Vite hot-updates Vue/JS/CSS. C# changes require rebuild/restart. A published Host serves `wwwroot`, so client changes require `npm run build`.

2.3 Database table to form

1. Import a database table in Manage Data Models and preview differences.

2. Verify object/schema/key, nullability, calculated flags, and .NET types.

3. Mark database-generated IDs as Calculated/Generated.

4. Give foreign-key references semantic names such as `createdBy` and `updatedBy`.

5. Generate a form from the Data Model.

6. Verify each input `propertyName` to attribute mapping.

7. Create in Viewer, navigate to the returned record ID, reload, and verify update.

`propertyName` is a form data key. The Data Model attribute maps the column. Do not save through a control key such as `app_users.user_name`.

Reference display paths such as `createdBy.user_name` remain read-only. The server may populate the local `createdBy` audit attribute from `@userId`; relationship loading normalizes both sides by `DataValueKind`, so a freshly assigned `System.Guid` matches the canonical UUID string returned from the target table. The form must not submit the audit foreign key merely to display the referenced user name.

2.4 Control and value rules

  • Debounce or blur text input dependencies; do not validate the whole form per character.
  • Preserve Boolean Checkbox values as `true`/`false`. An unselected Radio Group or custom-value Checkbox is `null`; use Boolean Radio options when unanswered must remain distinct from an explicit No.
  • New Input and TextArea controls start with Full width enabled. In a Div whose children view is Row, direct fluid non-file Inputs share the available row width equally; a direct fluid TextArea occupies a complete row. Clear Full width to opt out, or use the Div children-container style for an intentional layout override. Existing saved controls are not migrated.
  • A persisted Data Model record exposes every available logical primary key in both `keys` and `data`. FormReader retains server data without a matching control in a separate read-only condition context, so a normal single-key model may use `data.id` directly in visible or read-only conditions (or write `Boolean(data.id)` or `data.id != null`) without adding a hidden ID control. These two condition types use normal JavaScript truthy/falsy semantics. Custom validation must still return an explicit Boolean and may use `Boolean(data.id)` or `data.id != null`. This context is excluded from `getData()` and the submission payload. New-record `initialData` does not synthesize a key. `keys`/`recordKeys` remains the server-authoritative update/delete identity. If mapped form data already owns the same property name, the server preserves that value instead of overwriting it.
  • Hidden controls are not submitted.
  • Required/custom rules are validation; a tooltip is not.
  • Error CSS styles the control; message positioning is separate.
  • Pagination may validate/save per page; final Submit validates all pages.
  • Runtime collections may use APIs; Designer uses preview rows.
  • DataGrid creation uses the Columns/API-driven inline editor modal by default. Set **Create display** to **Open edit form in new window** to reuse the configured Edit form as a normal Viewer for a new record. An empty or currently unresolved Edit form falls back to the inline editor. The browser may present the new window as a tab, and the grid reloads when focus returns to the list window. The target form still enforces its own ACL, mapping, triggers, and department scope.
  • The DataGrid Edit form picker filters by the selected Data Model in Server/ORM, Management API, and Static modes, listing only forms with the same `metadata.mapping.entityId`. It lists no candidates without a selected Data Model. A previously saved incompatible ID remains visible as the current value so opening the properties panel never silently deletes it.
  • Selecting **Custom expression…** in the Edit form picker writes the starter template `{editFormId}` and immediately reveals the input below. Replace it with a substitution resolvable from the host form runtime. It is not JavaScript and is not evaluated separately for each DataGrid row.

An API-mode collection URL may include a fixed server-scope query such as `?location=dashboard`. Runtime paging, search, and sort values are merged into that query with `mergeQueryParameters`; never append a second `?`. A non-empty runtime value replaces the same key, while an empty runtime value does not remove a fixed key. This URL handling does not replace server-side validation or authorization.

The application shell uses `768px` as its navigation breakpoint. Desktop keeps the collapsible, resizable `SystemLeftPane`; mobile reserves no layout width for the left pane and opens that same `SystemLeftPane` as an off-canvas drawer from a header button at the top right. Do not duplicate the menu form or its permission logic for mobile. The drawer closes through its backdrop, close button, `Escape`, route/menu selection, traps keyboard focus while open, and locks background scrolling. Standalone pages and Full viewport forms do not show shell navigation.

2.5 Substitution and i18n

Use `{name}`, `{row.name}`, number/date formats, and substitutions in action parameters. Fixed system-form text uses `@message.key` backed by Chinese and English catalogs. User-authored business content normally remains literal data. Form-owned `displayName` and `description` overrides live in the definition's `translations[locale]`; they are literal (no `@`), fall back field by field to the defaults, and are deleted with the form. Reserve an explicit top-level `@key` for fixed names shared by forms or modules. An unprefixed default never queries the catalog, and an unprefixed legacy `form.*` key is not supported. The technical form `name` is never localized.

Designer keeps only the technical `name` in its top toolbar. Edit `displayName`, `description`, their locale overrides, `isActive`, `hidden`, and Administrator-controlled department ownership in **Form settings / Action Code**. Relational FormStorage idempotently adds `form_definitions.translations_json` in its actual database at startup without changing a released Core migration. `hidden` only suppresses non-Administrator Form Center discovery before pagination; it does not replace ACL, department scope, `isActive`, or direct-route authorization.

2.6 Beginner diagnostics

Inspect Network URL/method/status/payload/error body; identify Designer versus Preview versus Viewer; verify form/record/deployment IDs; inspect form type, anonymity, mapping, and entity metadata; and use SQL logs to verify projection/filter/sort. `SurveySystemFormInitializer` uses the complete code-default schema only when a form is missing; an ordinary restart must never replace an existing Designer schema. Narrow additive repairs may still add a missing contract field or metadata entry, including the multi-tenant `departmentId` control and Data Model mapping, while preserving existing controls, layout, and explicit custom mappings. `AppUsersFormInitializer` is stricter: it only creates a missing form and preserves all existing Schema and metadata; update its source Schema/Action Code explicitly with `tools/sync-system-forms.js --form AppUsersForm`. Back up and merge any destructive platform change deliberately rather than hiding it in startup seeding.

A Survey form becomes immutable when any deployment that uses it has response rows. Designer and metadata saves then return the stable `409 survey.formLocked` contract. This is intentional: changing the original schema would make collected answers ambiguous or inconsistent with the deployment-owned response table. The response deliberately omits deployment identifiers because form-design permission does not imply read access to every deployment that may use a shared form. Copy the form to create a new version, change that copy, and use it in a new deployment; do not update or delete the collected responses to bypass the lock.

The respondent deployment dashboard projects `formName`, raw `completionStatus`, and localizable `completionStatusText` only after assignment, time-window, and department filtering. Status is `not_started` when no bound response exists, `in_progress` when the latest response is a draft, and `completed` when the latest response is complete. Because every deployment owns a dynamic response table, query progress only for the current deployment page and batch the form-summary lookup. Keep Form and Progress as independent ItemRenderer card children so a Designer can hide or delete either presentation without changing the API contract.

After a respondent opens a survey, the session bar exposes **Print / PDF**. Printing uses the currently rendered browser state, so `not_started`, `in_progress`, and `completed` surveys are printable and unsaved field values can appear in the output; printing never creates or updates a response. Print CSS hides session actions, form buttons, and pagination navigation while expanding every visible Pagination and Tab pane. `?print=1` remains the automatic-print entry and no longer requires a `responseId`; the normal deployment/respondent read authorization still applies.

2.7 Exercise

Create an `InventoryCategory` Data Model and CRUD form, then a product form with an AsyncSelect category and a searchable/sortable/exportable DataGrid. Verify authorization and bilingual labels.

3. Intermediate: extend services and runtime

3.1 Outcomes

You should be able to use ORM transactions, add domain services/APIs/actions/triggers, create controls that work in all modes, use common errors/toasts/i18n/ACL, add migrations, and write integration/E2E tests.

3.2 Server features

First-party endpoints belong in the appropriate `Hosting/Endpoints/*Endpoints.cs`; customer features should be modules. Endpoints bind and authorize; services implement rules.

group.MapPost("/", async Task<IResult> (
    SaveRequest request, ClaimsPrincipal principal,
    ItemService service, CancellationToken ct) =>
{
    var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier)
        ?? throw new UnauthorizedAccessException("Login is required.");
    var saved = await service.SaveAsync(userId, request, ct);
    return Results.Created($"/api/items/{saved.Id}", saved);
});

Reusable conflicts throw `PlatformApiException`; do not invent `{ message }` or `{ error }` payloads.

3.3 ORM principles

Begin a unit of work, perform related reads/validation/writes in it, and commit explicitly. Query Specifications use registered entity, attribute, and reference names; the ORM quotes identifiers and parameterizes values.

Project only needed fields, filter/sort in the database, use cursor/keyset pagination for large sets, define distinct references for repeated foreign tables, and include every owner/tenant boundary in server filters. Handwritten SQL remains parameterized and identifier-whitelisted.

For a single-row compare-and-update such as inventory allocation, use SDK 1.8

`TryUpdateWhereAsync` with complete key equality, additional availability

filters, and `FieldUpdate.Increment`/`Set`. The comparison and mutation then run

in one statement without exposing SQL in the module. Do not replace it with a

read followed by `PatchAsync`, which permits lost updates under concurrency.

The method returns false when a condition no longer matches and participates in

the caller's unit of work and mutation guards.

Native identifier parameters must keep their database type. In an insert-time uniqueness query there is no current ID to exclude, so omit the `id <> @id` predicate and its parameter; never bind `""` as a sentinel for UUID/`uniqueidentifier`/binary identifiers. On update, include the predicate with the real typed ID. This keeps the same query semantics across PostgreSQL, SQL Server, and MySQL and avoids comparisons such as PostgreSQL `uuid <> text`.

3.4 Actions, triggers, and submissions

Server Actions implement `IServerActionsProvider`. Before triggers may change pending values; after triggers must respect transaction state. Substitute action parameters before typed parsing. General actions distinguish tokens such as `@userId`, `@Datetime`, and `@id` from literal strings and never expose arbitrary calculated-column writes.

Dependency tracking should evaluate only affected rules. Hovering an unrelated control must not run another field's custom validation.

3.5 New controls

A control needs a runtime component, Designer preview, property schema, relevant events, defaults, data/non-data classification, and async-loading policy. Business state is Vue reactive state, not `querySelector` mutations. DOM-required focus, measurement, print, or third-party integration is isolated behind refs and lifecycle hooks.

Verify Designer has no real API calls and is visually stable; Preview and Viewer share value/null/boolean semantics; disabled/read-only/required/error styles work; events are control-specific; attrs/emits are declared; and i18n, print, and container layouts work.

3.6 Authorization and migrations

Define the capability before the button. Read, design, delete, and survey assistance are independent checks; respondent routes also validate deployment assignment and time window. Client conditions improve UX but never replace endpoint checks.

Add a new immutable migration ID for each schema change. Never edit applied SQL because checksum enforcement will stop startup. Plan locking, batching, and recovery for data backfills.

3.7 Exercise

Add a transactional inventory-adjustment Server Action with an audit row and 409 conflict response. Add a summary API and chart control with Designer mock data. Cover both with PostgreSQL integration and Playwright tests.

4. Advanced: maintain the platform and ecosystem

4.1 Outcomes

Advanced developers design SDK/Host boundaries, audit identity and ACL risks, plan high-volume queries/caches/background work, maintain cross-provider migrations, establish reproducible CI/rollback, and decide whether a feature belongs in a form, control, module, or Core.

4.2 Boundary decisions

Prefer, in order: edit a form for layout/field/event change; create a reusable control/Runtime API for shared interaction; create a module for tables/APIs/actions/triggers; add to Core only when every installation needs it and platform lifecycle/security is involved.

The SDK exposes stable contracts, not Host implementation. New SDK APIs require version-range, XML documentation, binary-compatibility, and all-module CI review. SDK 1.8 adds conditional atomic field updates with a default not-supported interface implementation so existing custom repository binaries continue to load.

`ISurveyOfflineGateway` is the reference pattern for a feature that is optional but must reuse a security-critical Core transaction. SDK 1.7 keeps the respondent package/submission surface stable, retains separate `ISurveyOfflineFileGateway` and `ISurveyOfflineOptionGateway` capabilities, and adds `ISurveyOfflineAssistanceGateway` rather than weakening respondent authentication. The Host owns deployment/form ACL, acting-respondent assignment and department authorization, SystemValue/rich-text processing, form/option fingerprints, optimistic response checks, option-key enforcement, file-content validation, quotas, audit identity, and transaction-level idempotency; `FormPlatform.Offline` owns the PWA, mode/owner-bound package grants, encryption, expiry policy, and outboxes. Deployment/form/department/response-version, operator/target identity, option grants, and quotas come from the server grant/module policy, never from untrusted sync authority. Modules must not copy `SurveyApplicationService` or reference the Host assembly.

Browser modules may register respondent-dashboard tools and reuse the stable `FormReader` component exposed by the client extension API. Call `preloadFormComponents(schema)` while online before declaring a package offline-ready; it resolves lazy control chunks without coupling the module to Vite filenames. A trusted host can pass runtime-only `fileHandler` (`stage`, `preview`, `remove`) and `optionHandler.query` callbacks. The latter lets AsyncSelect/Tree use an immutable local page/search source without changing ordinary online behavior. Neither callback may be serialized into form metadata. An offline application-shell route may be public so it can bootstrap without a network, but every package/data API must retain its own authentication and authorization. Option snapshots must be complete, bounded, versioned, expiring, encrypted at rest, re-authorized at sync, and rejected when dynamic filters cannot be reproduced safely.

4.3 Performance

Use database ACL candidate filtering and cursor pagination, explicit projections, batch form+metadata reads, content hashes/ETags for global CSS, cancellable requests, and background work for expensive mail/export/file operations. Avoid unbounded queries, large OFFSET, N+1 access, and sensitive parameter logging.

A keyset cursor contains stable sort keys plus ID. Deleting the referenced record does not invalidate `(sortKey,id) > cursor`, but concurrent writes provide a weak snapshot. Use transaction snapshots or server-side export jobs when strict consistency is required.

4.4 Security, providers, and time

Modules are trusted in-process code; isolate untrusted code behind a separate process/API. Authorize both upload and download, validate file limits/types, use OIDC provider subject to create an internal identity, hash passwords, hide internal 500 details, and rotate all secrets.

System-user and respondent cookies intentionally use the same `App_Data/keys` Data Protection key ring. Their distinct authentication schemes contribute distinct Data Protection purposes, so their tickets are not interchangeable. On Windows, newly persisted keys are encrypted with current-user DPAPI; existing plaintext key files are not rewritten, and the directory ACL remains a required security boundary.

Use `DateTimeOffset`/UTC for instants and Date for calendar dates. `datetime-local` has no offset and needs an explicit server timezone policy. PostgreSQL transactional DDL, MySQL implicit commit, and SQL Server conditional DDL require provider-aware migration review.

4.5 Release compatibility

Manifest version must match assembly Major/Minor/Build. Compatibility ranges are minimum-inclusive and maximum-exclusive. A safe release backs up, validates migrations, publishes Host/SDK, rebuilds all modules, deploys client assets, and performs smoke tests. Never pair a new manifest with an old DLL.

4.6 Advanced review checklist

Review SDK boundaries, unbounded/N+1 queries, authorization and ownership, common errors/i18n/trace IDs, irreversible migrations and recovery, Designer/Preview/Viewer/print consistency, PostgreSQL/Playwright coverage, and upgrade behavior for old forms/manifests/caches.

5. Team workflow

State the impact on forms, client, server, database, authorization, and tests before changing code. Keep changes focused and preserve unrelated dirty work. Update both handbook languages for public behavior and append `docs/OPENCODE_CHANGES.md`.

Review in this order: contract/security, data/migrations, domain behavior, API error contract, Vue reactivity/Designer, i18n/UX, tests/deployment.

6. Further reading