Getting started with ToolUp.Forms
Getting started with ToolUp.Forms
End-to-end walkthrough: define a schema, render the form, persist a submission, run a workflow.
Prerequisites
- A working ToolUp Platform app with
IEntityStoreenabled (ServerConfig.EntityStore = EnabledEntityStore). IAuditLogenabled for event emission.
1. Add the packages
In your server project's .fsproj:
<ItemGroup>
<PackageReference Include="ToolUp.Forms.Server" />
</ItemGroup>
In your client project's .fsproj:
<ItemGroup>
<PackageReference Include="ToolUp.Forms.Client" />
</ItemGroup>
2. Declare a schema
In a module's SharedTypes.fs:
module MyModule.SharedTypes
open ToolUp.Forms.FormSchema
let leadCaptureSchema : FormSchema =
FormSchema.create "lead-capture-v1" "Lead Capture" [
{ Key = "name"
DisplayName = "Your name"
Description = Some "How should we address you?"
Kind = TextField (Some 100)
Required = true
Validators = [ LengthRange (Some 2, Some 100) ] }
{ Key = "email"
DisplayName = "Email"
Description = None
Kind = TextField (Some 254)
Required = true
Validators = [
Regex (@"^[^@\s]+@[^@\s]+\.[^@\s]+$", Some "valid email address")
] }
{ Key = "company"
DisplayName = "Company"
Description = None
Kind = TextField (Some 200)
Required = true
Validators = [ LengthRange (None, Some 200) ] }
{ Key = "interest"
DisplayName = "What are you interested in?"
Description = None
Kind = ChoiceField [ "Demo"; "Pricing"; "Partnership"; "Other" ]
Required = true
Validators = [] }
{ Key = "notes"
DisplayName = "Anything else?"
Description = None
Kind = TextField (Some 2000)
Required = false
Validators = [ LengthRange (None, Some 2000) ] }
]
FieldKind shipped variants:
| TextField of maxLength: int option
| NumberField of min: float option * max: float option
| DateField
| DateTimeField
| BoolField
| ChoiceField of options: string list
| MultiChoiceField of options: string list
| FileField of allowedTypes: string list
| EntityRefField of entityType: string
| NestedFormField of FormSchemaId
ValidationRule shipped variants:
| Regex of pattern: string * description: string option
| NumberRange of min: float option * max: float option
| LengthRange of min: int option * max: int option
| Custom of name: string
There is no Required / Email / MinLength / MaxLength / MinValue / MaxValue rule — Required is a separate boolean on FieldSchema, "email" is a Regex rule with an email pattern, and MinLength / MaxLength / MinValue / MaxValue collapse into the bounded LengthRange / NumberRange rules with None for the open-ended side.
3. Declare a workflow (optional)
If submissions need workflow state, define a WorkflowDefinition:
open ToolUp.Forms.Workflow
let leadWorkflow : WorkflowDefinition = {
Id = "lead-workflow"
InitialState = "new"
Transitions = [
{ From = "new"
Event = "contact"
To = "contacted"
Guard = None
Action = None }
{ From = "contacted"
Event = "qualify"
To = "qualified"
Guard = None
Action = None }
{ From = "contacted"
Event = "mark-lost"
To = "lost"
Guard = None
Action = None }
{ From = "qualified"
Event = "send-proposal"
To = "proposal-sent"
Guard = Some "has-proposal-attached"
Action = Some "send-proposal-email" }
{ From = "proposal-sent"
Event = "mark-won"
To = "won"
Guard = None
Action = Some "send-welcome-email" }
{ From = "proposal-sent"
Event = "mark-lost"
To = "lost"
Guard = None
Action = None }
]
}
WorkflowState, WorkflowId, and TransitionEvent are type aliases for string — workflow authors pick natural names. Guard and Action are string option and resolve server-side at apply time against the registries populated by withGuard / withAction. Closures don't cross Fable serialisation; name-keyed dispatch is the workaround.
4. Wire FormsServerApp
In the server composition root:
open ToolUp.Platform.Server
open ToolUp.Forms.FormSubmission
open ToolUp.Forms.Workflow
open ToolUp.Forms.FormsCompose
let serverApp =
FormsServerApp.create ()
|> FormsServerApp.withConfig {
ServerConfig.defaults with
EntityStore = EnabledEntityStore
AuditLogMode = EnabledAuditLog
}
|> FormsServerApp.withAuth authProvider
|> FormsServerApp.addModules modules
|> FormsServerApp.withFormSchema MyModule.SharedTypes.leadCaptureSchema
|> FormsServerApp.withWorkflow leadWorkflow
|> FormsServerApp.withGuard
"has-proposal-attached"
(fun (submission, _ctx) -> async {
if submission.Values.ContainsKey "proposal_file_id" then
return Ok ()
else
return Error "no proposal file attached"
})
|> FormsServerApp.withAction
"send-proposal-email"
(fun (submission, _ctx) -> async {
let email =
match Map.tryFind "email" submission.Values with
| Some (TextValue s) -> s
| _ -> ""
// ... ship the email via your notification sink ...
return ()
})
|> FormsServerApp.withActionPolicy "send-proposal-email" DeadLetter
let exitCode = FormsServerApp.run serverApp
- Guards have signature
Submission * AccessContext -> Async<Result<unit, string>>.Ok ()allows the transition;Error reasonshort-circuits it asFormError.TransitionDenied reason. A guard that throws surfaces asGuardEvaluationFailed (guardName, reason)so callers can retry transient faults. - Actions have signature
Submission * AccessContext -> Async<unit>. They run after persistence, wrapped in theIActionLedgerlifecycle so each(SubmissionId, transitionId, actionName)triple fires at most once. withActionPolicysets the per-actionActionFailurePolicy(FailSubmission/DeadLetter/LogOnly). Without an explicit policy the engine defaults toDeadLetter.
5. Render the form on the client
In a module's ClientView.fs:
module MyModule.ClientView
open Feliz
open ToolUp.Forms.FormSchema
open ToolUp.Forms.FormSubmission
let formView
(schema: FormSchema)
(onSubmit: Map<string, FieldValue> -> unit)
=
Html.div [
prop.className "max-w-2xl mx-auto p-6"
prop.children [
Html.h2 [ prop.text schema.DisplayName ]
FormRenderer.render
{| Schema = schema
InitialValues = Map.empty
OnSubmit = onSubmit |}
]
]
FormRenderer.render is a Feliz component exposed by ToolUp.Forms.Client. It uses React.useState for in-flight values; only dispatches upward on submit. The user types freely without per-keystroke Elmish dispatch.
Client-side validation mirrors the server's; the server's is authoritative.
6. Submit + persist
The OnSubmit callback typically dispatches an Elmish Msg. The handler calls the API:
open ToolUp.Forms.FormApi
open ToolUp.Forms.FormSubmission
| SubmitLead values ->
let request : SubmitRequest = {
FormId = MyModule.SharedTypes.leadCaptureSchema.Id
Values = values
WorkflowId = Some leadWorkflow.Id
}
let cmd =
Cmd.OfAsync.either
(fun req -> FormsClient.proxy.Submit req)
request
(function
| Ok submission -> SubmissionSucceeded submission
| Error err -> SubmissionFailed err)
(fun ex -> SubmissionFailed (FormError.StorageFailed ex.Message))
model, cmd
IFormApi.Submit:
- Validates server-side via the schema's
ValidationRules + any custom validators. - Persists the
SubmissiontoIEntityStorewith declared indexes (FormId/Author/State/FormId+State). - Emits
FormSubmittedevent under_platform.audit. - If
WorkflowId = Some _, setsSubmission.State = Custom workflow.InitialState; otherwise sets it toSubmitted. - Returns the persisted
Submissionwith server-allocatedIdandSubmittedAt.
7. List + manage submissions
let submissionsView model dispatch =
FormSubmissionsList.render
{| Submissions = model.Submissions
Schema = MyModule.SharedTypes.leadCaptureSchema
Workflow = Some leadWorkflow
OnTransition = fun submissionId event ->
dispatch (TransitionWorkflow (submissionId, event)) |}
The list view shows one row per submission, formatted per schema. Workflow-aware rows show a WorkflowBadge (current state) + per-transition action buttons (filtered by what's permitted from the current state).
Transition handler:
| TransitionWorkflow (submissionId, eventName) ->
let request : ApplyTransitionRequest = {
SubmissionId = submissionId
Event = eventName
}
let cmd =
Cmd.OfAsync.perform
(fun req -> FormsClient.proxy.ApplyTransition req)
request
(function
| Ok submission -> TransitionSucceeded submission
| Error err -> TransitionFailed err)
model, cmd
The server runs the guard (if any), persists the new state, fires the action under the ledger, emits a WorkflowTransitioned audit event. A guard returning Error reason surfaces as FormError.TransitionDenied reason; a guard exception surfaces as GuardEvaluationFailed.
8. Optional — publishable surveys
For anonymous-respondent surveys (e.g. a customer-satisfaction NPS survey distributed via email):
open ToolUp.Forms.FormSchema
let npsSchema =
{ FormSchema.create "nps-q3-2026" "Q3 NPS" [
{ Key = "score"
DisplayName = "On a scale of 0–10, how likely are you to recommend us?"
Description = None
Kind = NumberField (Some 0.0, Some 10.0)
Required = true
Validators = [ NumberRange (Some 0.0, Some 10.0) ] }
{ Key = "why"
DisplayName = "Briefly — why?"
Description = None
Kind = TextField (Some 1000)
Required = false
Validators = [ LengthRange (None, Some 1000) ] }
]
with
Visibility = Publishable }
Wire IShareTokenStore:
open ToolUp.Platform.Server
open ToolUp.Forms.FormsCompose
let serverApp =
FormsServerApp.create ()
|> FormsServerApp.withConfig {
ServerConfig.defaults with
EntityStore = EnabledEntityStore
ShareTokenStore = EnabledShareTokenStore { Secret = signingSecret }
PublicBaseUrl = Some "https://my-app.example.com"
}
|> FormsServerApp.withFormSchema npsSchema
let exitCode = FormsServerApp.run serverApp
Issue tokens:
open System
open ToolUp.Forms.FormApi
let! result =
formsApi.IssueTokens {
SchemaId = npsSchema.Id
Recipients = [
for i in 1 .. 100 ->
{ Handle = $"panel-{i}"; DisplayName = None }
]
ExpiresAt = Some (DateTimeOffset.UtcNow.AddDays 30.0)
UseLimit = Some (Some 1)
}
// result : Result<IssuedToken list, FormError>
// Each IssuedToken has .Url = "{PublicBaseUrl}/r/{token}".
Distribute the URLs by email / SMS / panel provider. Anonymous respondents visit /r/{token} and submit the form via the PublicEmbed Feliz component (no app shell, no auth required).
Server-side IPublicFormApi.SubmitWithToken validates the token (signed, ResourceKind = "forms.publishable", schema is Publishable, not expired, use-limit not exhausted), validates the form values, persists with Author = InvitedRespondent (tokenId, attributedHandle), returns Ok submission.
See concepts.md for the full publishable-surveys flow.
Next steps
- concepts.md — schema model, validation chain, workflow engine, action ledger, publishable surveys, entity-store layer.
- api-reference.md — full public surface.
- extending.md — custom validators / guards / actions, registering an
IActionLedger, custom analysers, custom renderer.
Documentation as-of forge 32ef4aa.