Form controls reference
Detailed behavior, contracts and implementation guidance for FormPlatform input, layout, media and visualization controls.
Pagination component
Purpose
`Pagination` is a reusable multi-step form container. It owns page selection, per-page validation, error summaries, and Previous / Next / Submit navigation. It does **not** own a database provider, a survey deployment, or a server action. The surrounding `FormReader` host receives a normal submit event and decides persistence.
The same component can therefore be used by surveys, ordinary Data Model forms, functional forms, or an Action Code-controlled workflow.
Flow
1. **Previous** moves to the preceding visible page without validation.
2. **Next** evaluates the current page's validation mode: block, none, or validate-but-continue.
3. When Next may continue, it optionally persists the current form state, then advances.
4. **Submit** validates all visible pages, runs `onSubmit`, then performs the configured final completion action.
Visible page headers are progress navigation, not a validation bypass. A user can return to an already reached page; a future page header remains disabled until Next reaches it.
General settings
| Setting | Meaning |
|---|---|
| Show page headers | Shows headers in Preview and Viewer. Designer always shows them so pages remain editable. |
| Arrange page headers vertically | Uses a left-side page rail. Disabled uses horizontal, wrapping headers. |
| Submit data when clicking Next | Enables a save after the page may continue. |
| Next-page submission mode | `Standard form submission` emits a normal submit. `Survey progress save` emits `completionStatus: in_progress`. |
| Final-page completion mode | Selects the action after full validation. |
Final completion choices are:
- **Standard form submission**: normal validated FormReader submit; use for System, Data Model, and Functional forms.
- **Survey submission (Completed)**: validated submit with `completionStatus: completed`; use for deployed surveys.
- **Run Submit event only**: no automatic save; the `onSubmit` handler owns the workflow.
- **Automatic (legacy compatibility)**: preserves schemas created before these settings existed.
New Pagination controls default to standard final submission and do not save on Next. Existing schemas that lack these properties preserve their former survey-compatible behaviour.
Events tab
| Event | When it runs | Boundary |
|---|---|---|
| `onPrevious` | Before Previous changes page | No validation. |
| `onNext` | When current page may continue | Does not run when block validation fails. It can receive `valid: false` for validate-but-continue. |
| `onSubmit` | After all visible pages validate | Runs before automatic final submission. |
Each handler receives the usual Form Action context and `event.detail`:
{
pageId: 'page_2',
pageTitle: 'Contact details',
pageIndex: 1, // zero based
pageNumber: 2, // one based
isLastPage: false,
valid: true,
validationMode: 'block'
}
`onSubmit` also receives `completionMode`. A synchronous global handler may return `false` to cancel the automatic follow-up action. Async Form Action module functions run asynchronously and must handle their own failure result.
Examples
Ordinary business form
Select **Standard form submission** and leave Next saving disabled. The last page uses the normal Form Viewer save route and the configured submit-button redirect.
Deployed survey
Enable Next saving, choose **Survey progress save**, and select **Survey submission (Completed)** for the last page. The survey host receives `in_progress` on intermediate saves and `completed` on final submission.
Action-controlled completion
Select **Run Submit event only**, then enable `onSubmit` in Events with `completeRegistration`:
export default {
async completeRegistration({ api, event, data }) {
const result = await api.fetch('/api/registration/complete', {
method: 'POST',
body: JSON.stringify({ data, page: event.detail.pageNumber })
})
api.notify({ type: 'success', message: result.message || 'Completed.' })
await api.navigate('/registration/thank-you')
}
}
To use ordinary FormPlatform persistence from Action Code instead, call:
api.reader.submit({ redirectUrl: '/registration/thank-you' })
It validates through FormReader once more, then emits a normal host submission.
Limits
- Pagination validates only controls currently visible under normal form rules. Hidden controls are not submitted by FormReader.
- Navigation events belong to the Pagination component; the three virtual buttons are not separately persisted Button controls. Configure appearance in General and behaviour in Events.
- Survey completion status is meaningful only when interpreted by a survey-deployment host. In a normal viewer it is an unused submission option.
---
Calendar Component and Chinese Almanac Calculations
中文:CALENDAR_COMPONENT.zh-CN.md
Architecture and data flow
`FormCalendar.vue` remains a normal `value` / `update:value` form control. Its date-only value is a Gregorian civil string (`YYYY-MM-DD`); optional time remains the existing local `YYYY-MM-DD HH:mm` presentation contract. The month grid passes numeric civil year, month, and day values to `lunarCalendar.js`. That pure module returns the lunar label, solar-term label, and stem-branch values; it does not parse an ISO instant or read browser timezone state.
`LUNAR_INFO` is the sole 1900–2100 lunar-year data source. Bits `0x8000` through `0x10` encode the twelve regular month lengths, the low nibble identifies the leap month, and bit `0x10000` encodes the leap-month length. Conversion starts at the known 1900-01-31 / lunar 1900-01-01 epoch and subtracts those encoded year and month lengths. It does not infer alternating month sizes and does not maintain a second Chinese New Year or leap-month table.
Solar terms are defined by apparent geocentric solar longitude at 15-degree intervals. For each term, the implementation numerically solves the longitude crossing with a compact astronomical solar model and then converts that instant to a UTC+8 civil date using Julian-day arithmetic. The four 1901–2100 cases that the Hong Kong Observatory identifies as exceptionally close to midnight use explicit published civil-date overrides, preventing minute-scale model uncertainty from selecting the adjacent day. No browser timezone or fixed interval between terms participates in the result. The year pillar switches on the civil date of 立春; the month pillar switches only on the twelve 节 (小寒、立春、惊蛰…大雪) and derives its stem from the effective post-立春 year through 五虎遁.
Security boundaries
This is display-only, client-side calendar math. It makes no network request, has no database or API dependency, and does not accept executable input. Form values remain untrusted input at the normal form submission boundary; calendar calculations neither validate nor authorize submissions.
Compatibility and limits
The Vue props, Schema contract, `v-model` event, styles, and navigation behavior are unchanged. The year selector and arrow navigation are bounded to 1900–2100; an already stored out-of-range value is preserved in the input but the almanac footer degrades to dashes. `getSolarTerm(year, month)` retains the footer form and accepts an optional day to label a grid cell. `STEM_BRANCH_YEAR(year)` remains for legacy Gregorian-year callers; date-aware callers use `yearStemBranch(year, month, day)` and `monthStemBranch(year, month, day)`.
The stem-branch footer follows the selected form value when one exists. Before a value is selected, it uses today's civil date while the visible month is the current month; after navigating to another month it uses that month's first day. The control therefore does not silently write today into the form merely to show today's pillars.
Lunar conversion and solar terms return `null` outside 1900–2100 or for invalid civil dates. Dates before 1900-01-31 cannot be represented by the supplied lunar dataset. Day pillars intentionally accept any valid proleptic Gregorian civil date and return `null` only for invalid values, preserving their timezone-independent contract. Year and month pillars require the supported range because their boundaries depend on the supported solar-term calculation.
Validation
Run `npm run test:unit -- src/components/lunarCalendar.test.js` from `src/FormPlatform.Host/ClientApp`, then `npm run build`. The direct tests cover regular month sizes, leap-month start/end, all 24 term dates in 2024, near-midnight term dates in 2021/2051/2083/2084, 立春 and monthly 节 boundaries, cross-century day pillars, and invalid/out-of-range inputs. Expected lunar and solar-term dates are based on the Hong Kong Observatory's Gregorian-Lunar Calendar Conversion Tables (1901–2100): <https://www.hko.gov.hk/en/gts/time/conversion.htm>.
---
Chart components
FormPlatform provides six Chart.js controls: **Bar**, **Line**, **Scatter**, **Doughnut**, **Pie**, and **Radar**. They are read-only data controls: a chart can display static JSON, an API response, or a JSON-valued form/Data Model field. It never changes the bound value itself.
Add and configure a chart
1. In Form Designer, expand **Charts** and drag a chart to the canvas.
2. Open **General** and set its title, title size, legend position, responsive behavior, and height.
3. Select a data source:
- **Static JSON**: JSON held in the schema. Best for a fixed illustration or a small dashboard sample.
- **API**: a same-origin URL returning JSON. The Designer intentionally does not call it; it renders local sample data instead.
- **Form data binding**: the current form value. Set **Other → Property Name** and map it to a JSON Data Model attribute when persistence is required.
4. Choose **Simplified dataset** for one dataset configured from component properties. Clear it to pass native Chart.js `{ labels, datasets }` data unchanged.
Simplified data
For Bar, Line, Doughnut, Pie, and Radar, set **Data labels** and use a numeric array:
[12, 19, 8, 15]
["Q1", "Q2", "Q3", "Q4"]
For Scatter, the simplified value is an array of X/Y points; Data labels are normally empty:
[
{ "x": 4, "y": 12 },
{ "x": 9, "y": 5 },
{ "x": 17, "y": 18 }
]
The Dataset Label and Dataset Background Color properties provide the one dataset's display settings. Background Color accepts a CSS color such as `#2563eb`, `rgba(37,99,235,.7)`, or a JSON color array.
Full Chart.js data
Turn **Simplified dataset** off to supply a native Chart.js data object. This is required for several datasets, dataset-specific options, or advanced colors.
{
"labels": ["Q1", "Q2", "Q3", "Q4"],
"datasets": [
{ "label": "Orders", "data": [12, 19, 8, 15], "backgroundColor": "#2563eb" },
{ "label": "Returns", "data": [2, 4, 1, 3], "backgroundColor": "#f59e0b" }
]
}
The same object can be stored in a JSON Data Model column and loaded through Form and Data Mapping. Chart controls are recognized by generated-table previews as a JSON field.
API data source
The host includes anonymous example endpoints for testing only:
| URL | Use |
|---|---|
| `/api/examples/charts/sales` | Simplified numeric data: `{ "data": [12,19,8,15] }` |
| `/api/examples/charts/sales-full` | Full Bar/Line/Radar Chart.js data |
| `/api/examples/charts/scatter` | Simplified scatter points |
| `/api/examples/charts/scatter-full` | Full scatter Chart.js data |
For a production module, return either the direct chart value or wrap it in `{ "data": ... }`. A minimal endpoint looks like this:
api.MapGet("/monthly-sales", async (IOrderReport report, CancellationToken ct) =>
{
var rows = await report.MonthlySalesAsync(ct);
return Results.Ok(new
{
labels = rows.Select(x => x.MonthName),
datasets = new[]
{
new { label = "Sales", data = rows.Select(x => x.Total), backgroundColor = "#2563eb" }
}
});
}).RequireAuthorization();
Do not expose an unrestricted reporting endpoint merely because a chart is visible. Apply the module's authorization and validate all query parameters on the server.
Form data binding
Use form binding when the chart should render a JSON field already loaded with the record. For example:
1. Add a `Line chart`, choose **Form data binding**, clear **Simplified dataset**, and set Other → Property Name to `monthlySales`.
2. Add a JSON attribute named `monthlySales` to the data model and map it as readable.
3. Store the full Chart.js data object in that field.
The chart is read-only. Use a DataGrid, editable table, actions, or a dedicated input form to edit the data source.
Responsive layout
Each chart observes its rendered FormPlatform container, including flex rows, tab/page changes, GridLayout panes, and collapsing side panels. With **Responsive** enabled (the default), Chart.js reflows its drawing area to the available width. The control itself always uses `min-width: 0`, `max-width: 100%`, and an overflow boundary, so it cannot widen a form row beyond its parent.
For small screens, use a moderate fixed **Height**, keep legends at `top` or `bottom`, and avoid very long category labels. Chart.js automatically skips category ticks when appropriate. Do not set a fixed width through the Style Tab unless the enclosing container has the same responsive constraint.
Bar width
Bar charts provide **Bar width (px)** in the General tab. `0` (or an empty value) leaves sizing to Chart.js, which is the recommended responsive default. A positive value fixes the pixel width of every bar dataset, including datasets supplied in the full Chart.js data format. Use a modest width when the chart can become narrow; an unnecessarily large fixed value may cause bars to overlap or become clipped when there are many labels.
Installation note
The client now depends on `chart.js`. Run `npm install` once in `src/FormPlatform.Host/ClientApp` to update `package-lock.json`; then use the usual `npm run build` and host build/publish process. `npm ci` requires the updated lock file.
---
Rich Text Editor control
`Rich Text Editor` is a form data control for short articles, formatted notes, instructions, terms, and other HTML text entered by a user. It is available in the Designer **Controls** panel and is independent of the CMS module.
What is stored
The value is a string containing safe HTML, for example:
{
"articleBody": "<h2>Welcome</h2><p>Please read the <a href=\"/terms\">terms</a>.</p><ul><li>First item</li></ul>"
}
Set **Other → Property Name** to `articleBody` (or the target column/property name). The control then follows the ordinary form-data path:
- a mapped form writes the value to a string attribute through the ORM;
- a generic submission stores it in the submission data JSON;
- a survey stores it in the generated response table;
- a generated form table creates a text/string column for it.
For a relational data model use a sufficiently large text field: PostgreSQL `text`, SQL Server `nvarchar(max)`, or MySQL `longtext`. Do not map it to a short `varchar` unless the business rule deliberately limits the document length.
Designer configuration
1. Drag **Rich Text Editor** from **Controls** onto the form.
2. In **General**, set the label, label position, placeholder, minimum height, toolbar visibility, read-only/disabled state, and change debounce timeout.
3. In **Other**, set the Property Name, default value, required rule, validation, visible/read-only conditions, and events just as for other data controls.
4. When using a Data Model, add a string attribute with the same Property Name and create or update the form mapping.
The initial editor value is empty. Use **Other → Default value** only when a real default document is intended; it will be submitted like any other default form value.
Editing experience
The built-in toolbar offers bold, italic, underline, paragraph, heading, bulleted list, numbered list, quote, link, and clear-formatting operations. The control supports the standard `onChange`, `onFocus`, and `onBlur` events. `onChange timeout` coalesces rapid edits before the action chain is invoked.
In read-only mode the same sanitized HTML is rendered as formatted content. This makes the control suitable for both edit and view forms.
Safety model
Rich text is treated as **data**, never as executable markup. Both browser and server apply the same small allow-list before the value is rendered or persisted.
Allowed elements are paragraphs, line breaks, headings, emphasis, underline/strikethrough, lists, block quotes, code/preformatted text, and links. An anchor retains only a safe `href`: `http`, `https`, `mailto`, root-relative URLs, and hash links.
Scripts, event attributes, styles, embedded frames, forms, SVG/MathML, images, arbitrary classes, and dangerous URL schemes are removed. Therefore this control intentionally does **not** accept Tailwind classes or custom HTML layout. Use normal FormPlatform containers, Style Tab settings, CMS blocks, or a purpose-built custom component for layout and media.
The server-side sanitizer runs before mapped entity saves, generic submissions, survey submissions, and assisted survey submissions. Code that accepts raw HTML through a custom module/API must still regard that input as untrusted and should call `FormRichTextSanitizer.SanitizeHtml(value)` when it wants this policy.
Example form fragment
{
"id": "articleBodyControl",
"type": "richText",
"props": {
"label": "Article body",
"placeholder": "Write the announcement…",
"minHeight": 280,
"toolbar": true,
"fluid": true
},
"other": {
"propertyName": "articleBody",
"required": true,
"validationTrigger": "blurAndDebouncedInput"
}
}
Deliberate boundaries
- It is not a CMS page builder and does not replace structured CMS blocks.
- It does not upload images or files. Use the File Upload control or shared media service and place an approved link in the text if needed.
- It does not promise exact Word/Google Docs formatting after paste; unsupported formatting is removed on purpose.
- It is not a collaborative editor. Concurrent users follow the same last-write/version rules as other form fields.
---
Signature and Camera controls
`Signature` and `Camera` are first-class, image-producing form data controls. They use the existing FormPlatform media services instead of placing image bytes in the submitted form record.
Add a control
In Designer, open **Input controls** and drag either **Signature** or **Camera** into the form. Assign a unique **Property name** on the **Other** tab when the control participates in a Data Model mapping, generated table, generic submission record, or survey response table.
Both controls have the standard required, custom validation, readonly, visibility, styling and `onChange` settings.
Signature
The respondent draws on a pointer-enabled canvas (mouse, pen, or touch). The canvas creates a PNG only when **Upload signature** is selected.
- **Clear canvas** removes the unsaved drawing.
- **Cancel** discards the unsaved drawing.
- **Upload signature** uploads a PNG and puts its returned media reference into the form value.
Clear and Cancel intentionally do not delete an already-saved signature. Uploading a replacement changes the form field value; normal storage retention/cleanup policy governs an older unreferenced asset.
The General tab configures canvas height, pen color, background color, and stroke width.
Camera
**Open camera** opens a focused dialog first. Once Vue has mounted the dialog and its `<video>` preview element, the control asks the browser for a stream and waits for the preview to become playable. This mirrors the historical Camera control's modal lifecycle and avoids attaching a native stream to an element that does not yet exist. If the request fails, the dialog keeps the diagnostic message and provides **Retry camera**. The control can request the front or rear camera, capture a JPEG, or accept a local image using **Choose from device**. A selected or captured image is only persisted after **Upload photo**.
Camera access requires HTTPS (or `localhost`) and a browser permission grant. Camera requests the configured front/rear facing mode first, then falls back once to bare `video: true` when the preferred profile cannot start. The dialog also lists the browser-visible cameras. Choose a concrete device when a desktop has more than one webcam, virtual camera, or capture device; that explicit selection is requested by `deviceId` and is retained only in that browser for that control. It is never stored in the form definition, because browser device IDs are origin-specific and can change. Select **Automatically select a camera** to return to the normal front/rear preference.
A transient driver-start failure receives one short delayed retry rather than repeatedly opening every visible device; this is intentionally conservative because rapid native open attempts can keep some Windows camera drivers in a `NotReadableError` state. Browsers intentionally do not reveal which application holds a camera; if it continues to fail, close other browser tabs, conferencing tools, native camera applications, and then retry. Local file selection remains available when the camera is unavailable or the user declines permission.
Only one FormPlatform Camera control may hold or be opening a browser video stream at a time. Opening another Camera control on the same page invalidates the earlier FormPlatform request or releases its live stream. A browser request cannot be cancelled directly, so a late stream from an invalidated request is stopped as soon as it resolves. This avoids hidden, repeated, or nested form renderers contending for a single physical camera.
Image upload mode
The General tab provides an image-specific upload mode:
| Mode | Behaviour |
|---|---|
| Auto | Survey deployment: survey attachment. Other saved forms: FormPlatform shared media. |
| Platform form shared media | Stores in the normal form-media service. Use this for ordinary mapped, generic, or functional forms. |
| Survey attachment | Stores in `survey_files`; use only in a deployed survey. |
| Third-party custom API | Sends the image to the configured API URL. The API must return either an ID string or an object containing an `id`. Return `downloadUrl` as well when the control must show the uploaded image immediately. |
For shared media, choose **Private** or **Public** visibility. A normal form must already be saved before it can upload shared media. Designer deliberately never uploads images.
Persistence and mappings
The field value is a media reference object while the browser is running; normal relational mapping stores its stable `id` in a string column. A form-generated table therefore creates a string field for Signature and Camera. In a manually designed Data Model, use a string/varchar attribute (normally length 36 for the current FormPlatform ID convention).
Survey responses store the attachment ID in the response table. When the response is loaded, FormPlatform resolves it back to a download reference so both controls show the saved image.
Security boundary
The standard `/api/forms/{formId}/media` endpoint verifies that the requested control is a Signature or Camera control configured for form/shared-media upload. It does not accept an arbitrary control ID. Survey upload retains its deployment, form and respondent ownership checks. Custom APIs are owned by the form/module author and must implement their own authorization and validation.
Example schema fragment
{
"id": "customerSignature",
"type": "signature",
"props": {
"label": "Customer signature",
"height": 180,
"penColor": "#0f172a",
"backgroundColor": "#ffffff",
"strokeWidth": 2,
"imageUploadMode": "auto",
"imageVisibility": "private"
},
"other": { "propertyName": "customer_signature", "required": true }
}
Use `type: "camera"` with `preferredCamera: "environment"` and `imageQuality: 0.9` for a photo field.
---
FileUpload control
`FileUpload` is the standard file-data control for ordinary forms and survey forms. It is separate from the legacy `Input` control with `type = file`: it supports an upload queue, multiple selections, drag-and-drop, configurable file rows, image thumbnails, and persisted platform media references.
Add it to a form
1. In Designer, drag **File upload** from the data controls panel.
2. Set its **Property name**. This is the form-data property and, when the form is mapped, the mapped attribute name.
3. In **General**, choose the presentation and storage properties described below.
4. Save the form before testing a platform-managed upload. The form ID is required to authorize and own a shared-media upload.
When a file is uploaded through FormPlatform, the form value is a media reference, not file bytes. A single-file control produces one reference; a multiple-file control produces an array of references. This keeps the response/form table small and lets the media service enforce download authorization.
General properties
| Property | Meaning |
|---|---|
| Label / label position | Standard data-control label. |
| Button text | Text of the picker button when **Use DropZone** is off. |
| Accepted file types | Browser `accept` hint, for example `.pdf,image/*`. It improves selection UX but is not a security policy. The server/media provider remains authoritative. |
| Image file types for icons | Comma-separated filename patterns, such as `*.png,*.jpg,*.gif`. Used to choose an image icon/preview when the MIME type is unavailable. |
| Show file type icon | Shows a compact file-type icon for each selected or uploaded item. |
| Auto process queue | Uploads selected files immediately. When off, files remain queued until the user clicks **Upload queue**. |
| Use DropZone | Replaces the picker button with a clickable drag-and-drop target. It accepts the same files and options. |
| Show image preview | Shows thumbnails for image items where the browser has a local preview or the provider returned a download URL. |
| Allow safe preview | Adds a separate **Preview** link for images, PDF, plain text, CSV, and JSON. It opens in a new tab. HTML, Office documents, executables, and unknown types are never previewed by this control. |
| Allow multiple choice | Enables browser multiple selection and stores an array of references. Generated database tables use a JSON column for this value. |
| Read only / Disabled | Standard controls. Read-only still displays existing uploaded items and download links; neither state permits selecting/removing files. |
| ID field | Field used as the identity when a custom upload API returns an object. Use `id` for FormPlatform media. Nested paths are accepted, for example `result.fileId`. |
File properties
The File properties table controls how each uploaded item is presented; it does not duplicate the file into the form record.
- **File property** is the supplied logical row (`name`, `length`, `contentType`, or `token`).
- **Column title** is the display label.
- **Table field** is the property read from a returned media-reference object, such as `fileName`, `fileSize`, `contentType`, or `id`.
- **Show property** enables the row. Use only `Name` for a compact attachment list, or add `Length` and `Content type` for a detailed list.
Style areas
The normal **Style** tab settings still control the FileUpload component wrapper. FileUpload additionally exposes semantic internal areas in the same tab, so styles do not depend on fragile DOM selectors:
| Area | Element styled |
|---|---|
| Label | The top, left, or right FileUpload label. |
| File frame | The bordered file-control frame. |
| Picker / queue button | The file-picker button and the queued upload/clear buttons. |
| DropZone | The drag-and-drop selection area. |
| File list | The container for saved and queued rows. |
| File item | Each saved or queued file row. |
| File information | The flexible filename/metadata section of each row. |
| File action | Preview, download, and clear actions. |
| Thumbnail | An image thumbnail when image preview is enabled. |
| Queue actions | The manual queue upload/clear strip. |
Enter Tailwind utilities or a custom CSS class for each area, for example `rounded-xl border-slate-300 shadow-sm` for **File frame** or `bg-blue-600 text-white` for **Picker / queue button**. These fields are stored with `*Class` names, so their Tailwind candidates are collected when the form schema is saved. The component intentionally retains these semantic structural elements; removing them would make list layout and per-area customization less reliable.
Storage modes
| Mode | Use | Result |
|---|---|---|
| Auto | Default | In a survey deployment, uploads to survey attachments. In any other saved form, uploads to FormPlatform shared form media. |
| Platform form shared media | Normal secured form attachment | Uploads through the form-media endpoint. Private media can only be downloaded through authorized form access; public media may use the public media URL. |
| Survey attachment | Deployed survey | Uploads through the survey attachment endpoint and is validated against the deployment, form, and respondent when submitted. |
| Third-party custom API | Module/provider-owned files | Sends the selected file to the configured API URL. The API should return either an ID string or an object containing the configured ID field plus optional `fileName`, `fileSize`, `contentType`, and `downloadUrl`. |
| Manual (Action Code) | Custom browser workflow | Does not upload. The selected `File` (or `File[]` for multiple mode) is available through the form runtime file API for Action Code to process. The ordinary submitted value is the filename unless Action Code replaces it. |
For FormPlatform-managed modes, **Media visibility** is `private` by default. Choose `public` only for files that are safe to expose outside normal form authorization.
Download and preview behavior
Every **Download** link sent by the built-in FileUpload control requests `Content-Disposition: attachment`, so the browser downloads the file instead of opening it inline. This includes images, PDFs, and text files.
**Preview** is a separate, opt-in control property. It only appears for a conservative allow-list of safe document types and opens a new tab without giving that page access to the opener. A custom upload API owns its download URL and may choose its own behavior; FormPlatform does not append its download parameter to an external URL.
Data Model mapping and table generation
- Map a single-file Property name to a nullable string/varchar attribute. The stored value is the media ID/reference ID.
- Map a multiple-file Property name to a JSON attribute. For tables generated from the form, the generator selects JSON automatically when **Allow multiple choice** is enabled.
- The control is a normal data control: required validation checks that at least one uploaded reference exists, and `onChange`, `onFocus`, and `onBlur` work through the normal event chain.
- For surveys, the response table persists file IDs. When loading a response, the server resolves IDs to download-reference objects only after verifying the current respondent or administrative access.
Changing **Allow multiple choice** changes the response column shape. Do this before a deployment has collected data. Survey tables with data are intentionally not rebuilt automatically.
Custom API example
Configure **Third-party custom API** and set Upload API URL to `/api/acme/documents`. A compatible response can be:
{
"id": "9c601af0-3b28-4f18-a8b1-6f6136d9d2b9",
"fileName": "proposal.pdf",
"fileSize": 128004,
"contentType": "application/pdf",
"downloadUrl": "/api/acme/documents/9c601af0-3b28-4f18-a8b1-6f6136d9d2b9/download"
}
The custom endpoint must authenticate and authorize the caller itself. It must validate file size/content, store the file outside the form-data record, and return only the metadata necessary for the control.
Security notes
- `accept` and filename extension checks are user-interface aids, not trust boundaries.
- Platform form media endpoint authorization is limited to a `FileUpload` control present in the saved form schema and configured for `auto` or `form` storage.
- Survey attachment IDs are revalidated on submission; a user cannot submit another respondent's file by posting an arbitrary ID.
- Do not make uploaded documents public unless the business case explicitly requires public download.
- Do not use file preview for untrusted HTML or Office documents. Keep **Allow safe preview** off unless the form needs it.
Legacy Input file control
Existing forms using `Input` with `type = file` continue to work. Use `FileUpload` for all new multi-file, queue, dropzone, thumbnail, or configurable file-row scenarios.
---
CustomBlock control
`CustomBlock` is a container that can either own normal child controls or render a reusable component tree from a saved form or JSON. It is useful for reusable presentation fragments such as a standard notice, a shared static navigation section, or a predefined component layout.
Source types
| Source type | Behavior |
|---|---|
| Drop controls | Default container mode. Drag, sort, and configure children directly inside the block. The children are saved in the current form schema. |
| Referenced form | Loads `schema.components` from the saved form identified by **Form Name**. The source is rendered read-only inside the current form. |
| JSON source | Parses a component array, `{ "components": [...] }`, or `{ "schema": { "components": [...] } }`, and renders it read-only. |
Referenced form authorization
Referenced forms are loaded through the existing `GET /api/forms/by-name/{name}` API. The current user must have read access to the referenced form. This is deliberate: embedding a form cannot bypass Form Access rules.
The API is authenticated, so use **JSON source** or regular local children for an anonymous/public page unless the relevant source is provided through an authorized application flow.
Designer behavior
- In **Drop controls** mode, CustomBlock is a normal editable Designer container.
- In **Referenced form** and **JSON source** modes, the source is a preview-only reusable fragment. Editing it from the parent would silently create a copy and make ownership unclear, so edit the referenced form or JSON instead.
- Invalid JSON and unavailable/unauthorized referenced forms render a visible error rather than silently showing an empty block.
JSON example
[
{
"id": "welcome-title",
"type": "header",
"props": { "content": "Welcome", "size": 2 }
},
{
"id": "welcome-note",
"type": "staticContent",
"props": { "value": "This section is supplied by CustomBlock." }
}
]
Nested referenced blocks are limited to six levels. This prevents a form from indirectly referencing itself forever.
---
SystemValue / ReadOnlyData control
`SystemValue` is a data control whose displayed value is read-only and whose authoritative value is written by FormPlatform immediately before persistence. It is intended for audit and request context fields such as submission time and authenticated identity.
The control participates in ordinary Data Model mappings, generic submission records, survey deployments, and ACL-authorized system-user assisted survey entry. It is deliberately not validated as an input field.
Add and configure
1. Drag **System value** from the control panel.
2. In **Other**, set a unique **Property Name**, for example `submitted_at`.
3. In **General**, select the source and capture mode.
4. When using a Data Model, map that property to an appropriate attribute. Use a calculated/default database column only when the database, rather than FormPlatform, should own the value.
On a new record, Viewer, deployed-survey, and saved-form designer preview pages request one short-lived server display snapshot and show the real current server time, request IP, identity, and so on. This snapshot is never trusted: immediately before the actual write, the server evaluates every SystemValue again and overwrites the browser payload. A loaded record displays its stored value. A new or not-yet-saved SystemValue control retains the non-authoritative *Recorded by the server when saved* placeholder because the server has no saved form definition to evaluate.
When viewing or editing an existing record, a SystemValue configured for **Update only** or **Create and update** keeps its stored value in the main output and also shows a blue **Will update to on next save** line. That second value is a fresh server preview and is never inserted into the browser submission model. This makes the saved and prospective values explicit rather than making an unsaved update look persisted.
Sources
| Source | Stored value | Notes |
|---|---|---|
| Server time | ISO UTC, date, Unix seconds, or a custom .NET format | Use `always` for `updated_at`; use `insert` for `submitted_at`. Custom formats are stored as text. |
| Client IP address | Resolved remote IP | Configure forwarded headers correctly behind IIS/Nginx. IPv4 and IPv6 are preserved. |
| Current system user ID / name | Current authenticated system principal | Empty for anonymous and respondent-only requests. |
| Current respondent ID | Survey respondent id | Useful on a deployed survey; empty on normal system forms. |
| Browser User-Agent | Request user-agent header | Informational; clients can change it. |
| Request path | Public request path | Query string is intentionally excluded. |
| Request trace ID | ASP.NET Core trace identifier | Useful for correlating a response with server logs. |
| Browser time zone | Browser supplied IANA zone | Informational only; the browser sends it in `X-FormPlatform-Time-Zone`. |
| Constant | Configured text | Server still overwrites the field, preventing payload substitution. |
Capture modes
| Mode | Effect |
|---|---|
| Create only | Writes on an insert, retaining the previously stored value on update. |
| Update only | Writes only when an existing record is updated. |
| Create and update | Rewrites on every save. |
Recommended audit fields
| Property name | Source | Capture mode | Suggested data type |
|---|---|---|---|
| `submitted_at` | Server time / ISO | Create only | `DateTimeOffset` / timestamp with time zone |
| `updated_at` | Server time / ISO | Create and update | `DateTimeOffset` / timestamp with time zone |
| `submitted_by` | Current system user ID | Create only | Guid / native UUID |
| `updated_by` | Current system user ID | Create and update | Guid / native UUID |
| `respondent_id` | Current respondent ID | Create only | Guid / native UUID |
| `client_ip` | Client IP address | Create and update | string / varchar(45) |
| `request_id` | Request trace ID | Create and update | string |
For survey deployment table generation, SystemValue server-time fields generate a date, 64-bit integer, or `DateTimeOffset` field for the built-in formats. A custom time format and other sources generate text fields.
Use **Custom format** when the presentation or integration requires a fixed string, for example `yyyy-MM-dd HH:mm:ss`. It uses the .NET `DateTimeOffset` custom format syntax and always formats UTC server time.
Trust model
The submitted JSON is cloned, then SystemValue properties are overwritten on the server before triggers, Server Action dispatch, generic persistence, Data Model persistence, or survey persistence. Editing browser devtools or posting a forged JSON value cannot replace a server-owned source.
`browserTimeZone` and `userAgent` describe the client but are not security claims. `clientIp` is reliable only when the reverse proxy forwarding policy is restricted to trusted proxies; otherwise an untrusted forwarded header must never be trusted.
Example form fragment
{
"id": "comp_submitted_at",
"type": "systemValue",
"props": {
"label": "Submitted at",
"labelPosition": "top",
"source": "serverTime",
"captureMode": "insert",
"timeFormat": "iso",
"fluid": true
},
"other": { "propertyName": "submitted_at" }
}
Do not mark a SystemValue as required. The server owns its value and assigns it at the persistence boundary.
---
Semantic Control Styling Standard
Purpose
Form definitions must be able to style every visible, meaningful part of a control without relying on fragile DOM selectors. This is the required design standard for every new FormPlatform control.
Contract
1. `style.customClass` styles the control's outer wrapper.
2. A control with multiple visible regions exposes one named object, for example `fileUploadStyles`, `chartStyles`, or `selectionStyles`.
3. Each member ends in `Class` and names a semantic region, never an implementation detail. Examples: `labelClass`, `frameClass`, `toolbarClass`, `optionClass`, `emptyClass`, and `errorClass`.
4. The Vue template applies the semantic class directly to the corresponding DOM element. User classes are appended after the stable semantic class.
5. Built-in component CSS uses `:where(...)` whenever possible, so an authored Tailwind utility can override it without `!important`.
6. Canvas-rendered content is configured through explicit rendering properties (colours, fonts, grid and legend options), because CSS cannot style pixels drawn by Canvas.
Existing control families
| Family | Style object / existing style surface |
|---|---|
| Input, Textarea, Rich text, System value | `inputStyles`, `textareaStyles`, `richTextStyles`, `systemValueStyles` |
| Upload and device controls | `fileUploadStyles`, direct GeoLocation class fields, `signatureStyles`, `cameraStyles` |
| Selection controls | `selectionStyles` on Dropdown, AsyncSelect, Tree, Radio, Checkbox and Calendar |
| Display controls | `headerStyles`, `imageStyles`, `qrCodeStyles`, plus outer `customClass` |
| Chart controls | `chartStyles` plus chart drawing properties |
| DataGrid, ItemRenderer, Menu, Message, Statistic, Breadcrumb, Tabs and Pagination | `semanticStyles` plus their existing documented column, pane, item, action and layout settings |
| Table, EditableTable and GridLayout | `semanticStyles` for frame, table/pane, header, body, row/cell, empty state and resize handle as applicable; their existing detailed cell/pane/layout settings remain available |
| Containers and simple content controls | outer `customClass`; children remain independent controls and own their styling |
Required implementation checklist for a new control
- Add a semantic `...Styles` prop with an empty-object default when it has more than one visible region.
- Add every `...Class` field to the Style tab, defaults in `ComponentPanel.vue` and `store/formDesigner.js`, and the component contract.
- Add English and Chinese labels in `i18n.js`.
- Add every class to the element it describes; do not require authors to know nested DOM structure.
- Keep defaults low-specificity with `:where`.
- Ensure the schema CSS collector can discover the class fields. The `Class` suffix is intentional.
- Add a focused component-contract/unit test when the control has interactive state.
Authoring example
For a multiple AsyncSelect, set:
{
"selectionStyles": {
"controlClass": "rounded-2xl border-indigo-300 bg-indigo-50",
"chipClass": "bg-indigo-600 text-white border-indigo-600",
"panelClass": "rounded-2xl shadow-2xl",
"selectedOptionClass": "bg-indigo-100 text-indigo-900"
}
}
This remains stable even if the implementation later changes from a `div` to a native dialog or a virtualized list.