Advanced Single Page Application
This section demonstrates how multiple sections of a page can be updated independently of each other. Each section has its own form and handler, and the page is updated without a full page reload. This uses a mixture of approaches but generally follows a pattern of utilizing query params for more ephemeral state like error validation messages along with a mechanism like GraphQL queries and mutations for more persistent storage. This approach allows for these interactions to continue to work without JavaScript enabled.
Name
In this section we're using a form to update the name. The form is submitted to the universal route handler and ultimately to the the server using the GraphQL mutation. This is followed by an update to either the DOM or the HTML response, depending on the context. The anchor ID is used to scroll to the correct section of the page after the page reloads for when JavaScript is disabled.
Hello, null!
match props.nameError with
| "invalid-name" -> Html.p "Please enter your name:"
| _ -> Html.p (sprintf "Hello, %s!" props.name)
req.Form {| id = "setName"; baseAction = "/set-name#setName"; method = "post"; children = [
Html.input [ prop.type' "text"; prop.key "name"; prop.name "name"; prop.placeholder "Name" ]
Html.input [ prop.type' "submit"; prop.key "submit"; prop.value "Change Name" ]
] |}
spa.post("/set-name", fun req res next ->
let newName : string = req.body?name
promise {
let! response =
req
|> gql "mutation ($name: String) { setName(inputName: $name) { success } }"
{| name = newName |} {||}
let name =
match response with
| Ok response -> { nameError = ""; name = newName }
| Error message -> { nameError = message; name = newName }
res.redirectBack<Name>(name)
} |> ignore
)
Color
This section follows the same basic pattern but uses FormButton components instead of a Form component. The interaction is still founded on the Form post but with a convenient button component that simplifies the process. Again, notice the use of the query parameters in the URL to drive the error handling. This is a key part of the artchitecture that allows for multiple sections of the page to maintain their state independently.
Click the buttons to change the color of this text.
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "red"; buttonText = "Red"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "green"; buttonText = "Green"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "blue"; buttonText = "Blue"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "error"; buttonText = "Error"|}
match props.colorError with
| "invalid-color" -> Html.p "Invalid color. Please select red, green, or blue."
| _ -> null
Html.div [
prop.style [ style.color props.color]
prop.children [ Html.p "Click the buttons to change the color of this text." ]
]
spa.post("/set-color", fun req res next ->
let newColor : string = req.body?color
promise {
let! response =
req
|> gql "mutation ($color: String) { setColor(color: $color) { success } }"
{| color = newColor |} {||}
let color =
match response with
| Ok response -> { colorError = ""; color = newColor}
| Error message -> { colorError = message; color = newColor}
res.redirectBack<Color>(color)
} |> ignore
)
Name and Email
This section demonstrates how to handle multiple form inputs with multiple validation requirements. The form is again submitted to the universal route handler. For demonstratinon purposes this route handler does nothing beside handle and report validation errors.
req.Form {| baseAction = "/set-name-and-email#setNameAndEmail"; method = "post"; children = [
Html.div [
prop.className "form-group"
prop.children [
Html.label [ prop.htmlFor "inputName"; prop.text "Name" ]
textInputFieldWithStringListError "inputName" "Name" props.inputName (Some props.inputNameErrors)
]
]
Html.div [
prop.className "form-group"
prop.children [
Html.label [ prop.htmlFor "inputEmail"; prop.text "Email" ]
textInputFieldWithStringListError "inputEmail" "Email" props.inputEmail (Some props.inputEmailErrors)
]
]
Html.input [ prop.type' "submit"; prop.key "submit"; prop.value "Submit" ]
] |}
spa.post("/form-validation", fun req res next ->
let inputName = req.body?inputName
let inputEmail = req.body?inputEmail
let nameValidator fieldName =
let msg = fun _ -> $"{fieldName} must be between 3 and 64 characters"
Check.WithMessage.String.betweenLen 3 64 msg
let emailValidator =
ValidatorGroup(Check.WithMessage.String.betweenLen 7 256 (fun _ -> "Email must be between 7 and 256 characters"))
.And(Check.WithMessage.String.pattern @"[^@]+@[^\.]+\..+" (fun _ -> "Please provide a valid email address"))
.Build()
let validatedInput =
validate {
let! inputName = nameValidator "Name" "inputName" inputName
and! inputEmail = emailValidator "inputEmail" inputEmail
return {| inputName = inputName; inputEmail = inputEmail |}
}
let inputNameAndEmail =
match validatedInput with
| Ok _ ->
{ inputName = inputName; inputEmail = inputEmail; inputNameErrors = [||]; inputEmailErrors = [||] }
| Error validationErrors ->
let inputNameErrors = extractErrors validationErrors "inputName"
let inputEmailErrors = extractErrors validationErrors "inputEmail"
{ inputNameErrors = inputNameErrors; inputEmailErrors = inputEmailErrors; inputName = inputName; inputEmail = inputEmail }
res.redirectBack<InputNameAndEmail>(inputNameAndEmail)
)
In the full code for the current page you'll see that we've encapsulated the components and route handlers into a single file using a single Express router that is later mounted onto our main application in a pattern that should seem very familiar to developers experienced with ExpressJS.
app.``use`` ("/single-page-application-advanced-demo" SinglePageApplicationAdvancedDemoRouter)
In the next section we will see the benefits of this architecture and how it can be used to create a separation of concerns between updating a UI based on user actions and then tracking those interactions.
Full Code For Current Page
module SinglePageApplicationAdvancedDemoPage
open Express
open Feliz
open Fable.Core
open Fable.Core.JsInterop
open Global
open AppLayout
open Validus
open SPACodeBlocks
[<Import("default", "router")>]
let router : unit -> ExpressApp = jsNative
let SinglePageApplicationAdvancedDemoRouter = router()
let private spa = SinglePageApplicationAdvancedDemoRouter
type InputNameAndEmail = {
inputName : string
inputEmail : string
inputNameErrors : string array option
inputEmailErrors : string array option
}
type Color = {
color : string
colorError : string option
}
type Name = {
name : string
nameError : string option
}
type SinglePageApplicationAdvancedDemoPageProps = {
color : Color
name : Name
inputNameAndEmail : InputNameAndEmail
}
[<ReactComponent>]
let nameSection props =
let req = React.useContext requestContext
React.fragment [
Html.h4 [ prop.text "Name"; prop.id "setName" ]
Html.p "In this section we're using a form to update the name. The form is submitted to the universal route handler and ultimately to the the server using the GraphQL mutation. This is followed by an update to either the DOM or the HTML response, depending on the context. The anchor ID is used to scroll to the correct section of the page after the page reloads for when JavaScript is disabled."
match props.nameError with
| Some "invalid-name" -> Html.p "Please enter your name:"
| _ -> Html.p (sprintf "Hello, %s!" props.name)
req.Form {| id = "setName"; baseAction = "/set-name#setName"; method = "post"; children = [
Html.input [ prop.type' "text"; prop.key "name"; prop.name "name"; prop.placeholder "Name" ]
Html.input [ prop.type' "submit"; prop.key "submit"; prop.value "Change Name" ]
] |}
nameComponentCodeBlock
nameHandlerCodeBlock
]
[<ReactComponent>]
let colorSection props =
let req = React.useContext requestContext
React.fragment [
Html.h4 [ prop.text "Color"; prop.id "setColor" ]
Html.p "This section follows the same basic pattern but uses FormButton components instead of a Form component. The interaction is still founded on the Form post but with a convenient button component that simplifies the process. Again, notice the use of the query parameters in the URL to drive the error handling. This is a key part of the artchitecture that allows for multiple sections of the page to maintain their state independently."
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "red"; buttonText = "Red"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "green"; buttonText = "Green"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "blue"; buttonText = "Blue"|}
req.FormButton {| baseAction = "/set-color#setColor"; name = "color"; value = "error"; buttonText = "Error"|}
match props.colorError with
| Some "invalid-color" -> Html.p "Invalid color. Please select red, green, or blue."
| _ -> null
Html.div [
prop.style [ style.color props.color]
prop.children [ Html.p "Click the buttons to change the color of this text." ]
]
colorComponentCodeBlock
colorHandlerCodeBlock
]
[<ReactComponent>]
let nameAndEmailSection props =
let req = React.useContext requestContext
React.fragment [
Html.h4 [ prop.text "Name and Email"; prop.id "setNameAndEmail" ]
Html.p "This section demonstrates how to handle multiple form inputs with multiple validation requirements. The form is again submitted to the universal route handler. For demonstratinon purposes this route handler does nothing beside handle and report validation errors."
req.Form {| baseAction = "/set-name-and-email#setNameAndEmail"; method = "post"; children = [
Html.div [
prop.className "form-group"
prop.children [
Html.label [ prop.htmlFor "inputName"; prop.text "Name" ]
textInputFieldWithStringListError "inputName" "Name" props.inputName props.inputNameErrors
]
]
Html.div [
prop.className "form-group"
prop.children [
Html.label [ prop.htmlFor "inputEmail"; prop.text "Email" ]
textInputFieldWithStringListError "inputEmail" "Email" props.inputEmail props.inputEmailErrors
]
]
Html.input [ prop.type' "submit"; prop.key "submit"; prop.value "Submit" ]
] |}
nameAndEmailComponentCodeBlock
nameAndEmailHandlerCodeBlock
]
[<ReactComponent>]
let SinglePageApplicationAdvancedDemoPage props =
let req = React.useContext requestContext
React.fragment [
Html.h3 "Advanced Single Page Application"
Html.p "This section demonstrates how multiple sections of a page can be updated independently of each other. Each section has its own form and handler, and the page is updated without a full page reload. This uses a mixture of approaches but generally follows a pattern of utilizing query params for more ephemeral state like error validation messages along with a mechanism like GraphQL queries and mutations for more persistent storage. This approach allows for these interactions to continue to work without JavaScript enabled."
nameSection props.name
colorSection props.color
nameAndEmailSection props.inputNameAndEmail
Html.p "In the full code for the current page you'll see that we've encapsulated the components and route handlers into a single file using a single Express router that is later mounted onto our main application in a pattern that should seem very familiar to developers experienced with ExpressJS."
SPAUseCodeBlock
Html.p "In the next section we will see the benefits of this architecture and how it can be used to create a separation of concerns between updating a UI based on user actions and then tracking those interactions."
Html.p [
req.Link {| href = "/analytics-router"; children = "Next: Analytics Router" |}
]
Html.h4 "Full Code For Current Page"
SPAFullCodeBlock
]
spa.get("/", fun req res next ->
promise {
let! response =
req
|> gql
"
query {
color { color }
name { name }
}
" {||} {| cache = false |}
match response with
| Ok response ->
let props = {
color = { color = response?color?color; colorError = req.query?colorError }
name = { name = response?name?name; nameError = req.query?nameError }
inputNameAndEmail = { inputName = req.query?inputName; inputEmail = req.query?inputEmail; inputNameErrors = req.query?inputNameErrors; inputEmailErrors = req.query?inputEmailErrors }
}
SinglePageApplicationAdvancedDemoPage props
|> res.renderComponent
| Error message -> next()
} |> ignore
)
spa.post("/set-name", fun req res next ->
let newName : string = req.body?name
promise {
let! response =
req
|> gql "mutation ($name: String) { setName(inputName: $name) { success } }"
{| name = newName |} {||}
let nameError =
match response with
| Ok response -> None
| Error message -> Some message
res.redirectBack<Name>({
name = newName
nameError = nameError
})
} |> ignore
)
spa.post("/set-color", fun req res next ->
let newColor : string = req.body?color
promise {
let! response =
req
|> gql "mutation ($color: String) { setColor(color: $color) { success } }"
{| color = newColor |} {||}
let colorError =
match response with
| Ok response -> None
| Error message -> Some message
res.redirectBack<Color>({
color = newColor
colorError = colorError
})
} |> ignore
)
spa.post("/set-name-and-email", fun req res next ->
let inputName = req.body?inputName
let inputEmail = req.body?inputEmail
let nameValidator fieldName =
let msg = fun _ -> $"{fieldName} must be between 3 and 64 characters"
Check.WithMessage.String.betweenLen 3 64 msg
let emailValidator =
ValidatorGroup(Check.WithMessage.String.betweenLen 7 256 (fun _ -> "Email must be between 7 and 256 characters"))
.And(Check.WithMessage.String.pattern @"[^@]+@[^\.]+\..+" (fun _ -> "Please provide a valid email address"))
.Build()
let validatedInput =
validate {
let! inputName = nameValidator "Name" "inputName" inputName
and! inputEmail = emailValidator "inputEmail" inputEmail
return {| inputName = inputName; inputEmail = inputEmail |}
}
let (inputNameErrors, inputEmailErrors) =
match validatedInput with
| Ok _ -> None, None
| Error validationErrors ->
let inputNameErrors = extractErrors validationErrors "inputName"
let inputEmailErrors = extractErrors validationErrors "inputEmail"
(Some inputNameErrors, Some inputEmailErrors)
res.redirectBack<InputNameAndEmail>({
inputName = inputName
inputEmail = inputEmail
inputNameErrors = inputNameErrors
inputEmailErrors = inputEmailErrors
})
)