These common examples can get started writing your flow.
When collecting user input, we use a FormLayout, which specifies a Schema, and a Button to submit the form using the specified Action.
val basicForm = Step.build {
id = "basicForm"
title = "Basic Form"
description = "The description property can work as a subtitle."
schemas {
obj {
id = "#login"
title = "Log in with your username or email."
required = listOf("username", "password")
properties {
string("username") {
title = "Username / Email"
}
string("password") {
title = "Password"
control = "password"
}
}
}
}
layout {
form {
schemaId = "#login"
}
}
footer {
button {
title = "Log in"
control = Control.Layout.Button.PRIMARY
behavior = ActionBehavior.build {
action {
url = "/login"
}
}
}
}
}To select value from a defined list you can use a oneOf schema with const children. The user can pick their choice from the dropdown, and then when button is clicked, the request body will contain the selection.
We can even pre-select one of the elements by specifying the step model.
val currencySelection = Step.build {
id = "currencySelection"
title = "Currency Selection"
schemas {
obj {
id = "#schema"
title = "Select a currency from the list."
required = listOf("currency")
properties {
oneOf("currency") {
title = "Currency"
schemas {
const {
title = "EUR"
value("EUR")
media = Media.Avatar.build {
content { uri { uri = "urn:wise:currencies:eur:image" } }
}
}
const {
title = "GBP"
value("GBP")
media = Media.Avatar.build {
content { uri { uri = "urn:wise:currencies:gbp:image" } }
}
}
const {
title = "USD"
value("USD")
media = Media.Avatar.build {
content { uri { uri = "urn:wise:currencies:usd:image" } }
}
}
}
}
}
}
}
layout {
form {
schemaId = "#schema"
}
}
footer {
button {
title = "Continue"
control = Control.Layout.Button.PRIMARY
behavior = ActionBehavior.build {
action {
url = "/submit"
}
}
}
}
model = buildJsonObject {
put("currency", "GBP")
}
}It's likely you'll want to alter the flow based on user input. There are a number of ways to do it depending on the input required and how the flow should progress.
If we simply want the user to make a selection, and let the backend handle the subsequent screen, we can use a DecisionLayout with multiple options. Clicking on one of the options loads the next step by submitting using the specified Action.
val countrySelection = Step.build {
id = "countrySelection"
title = "Country Selection"
layout {
decision {
title = "Please chose how to continue."
options {
option {
title = "France"
description = "Clicking this option loads the next step."
media = Media.Avatar.build {
content { uri { uri = "urn:wise:countries:fr:image" } }
}
behavior = ActionBehavior.build {
action {
url = "/payin"
data = buildJsonObject {
put("countryCode", "FR")
}
}
}
}
option {
title = "United Kingdom"
description = "Clicking this option loads the next step."
media = Media.Avatar.build {
content { uri { uri = "urn:wise:countries:gb:image" } }
}
behavior = ActionBehavior.build {
action {
url = "/payin"
data = buildJsonObject {
put("countryCode", "GB")
}
}
}
}
}
}
}
}Sometimes you will want to show different form fields depending on previous answers in the same form. Where you can, we suggest you split the form into multiple steps, and fork the flow instead - but sometimes this is not practical.
Here, instead, we use a oneOf schema with object schema children. This will conditionally show the fields associated with each option in the one of, after the user makes a selection.
Typically, this option is best when there are only a few items to choose from to keep the size of the step small.
val bankAccountDetails = Step.build {
id = "bankAccountDetails"
title = ""
schemas {
oneOf {
id = "#schema"
title = "Bank Account Details"
placeholder = "Please, select an option"
schemas {
obj {
title = "Inside Europe"
displayOrder = listOf("type", "iban")
properties {
const("type") {
title = "IBAN"
value("IBAN")
}
string("iban") {
title = "IBAN"
pattern = "^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$"
}
}
}
obj {
title = "Outside Europe"
displayOrder = listOf("type", "bic", "swift")
properties {
const("type") {
title = "SWIFT"
value("SWIFT")
}
string("bic") {
title = "BIC"
pattern = "^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$"
}
string("swift") {
title = "Swift"
pattern = "^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$"
}
}
}
}
}
}
layout {
form {
schemaId = "#schema"
}
}
}Like with any oneOf schema, we can pre-select one of the options by specifying the step model.
In the example above, we could specify a step model like the following to pre-select the "Outside Europe" option:
"model" { "type": "SWIFT" }
Dynamic Flow provides many ways to validate inputs. Which method you choose will depend on how and when you need to validate.
The easiest and fastest way to validate inputs is using local validation. Depending on the schema, we can provide additional properties that will be used to determine if the input is able to be submitted.
For example String Schemas support length validations and pattern validations.
val localValidation = Step.build {
id = "localValidation"
title = ""
schemas {
obj {
id = "#schema"
title = "Enter recipient account details"
properties {
string("iban") {
title = "IBAN"
pattern = "^[a-zA-Z]{2}[a-zA-Z0-9 ]{12,40}$"
minLength = 14
maxLength = 42
}
}
}
}
layout {
form { schemaId = "#schema" }
button {
title = "Submit"
behavior = ActionBehavior.build {
action {
url = "/sign-up"
method = HttpMethod.POST
}
}
}
}
}We can also send the value to the server before submission to provide earlier feedback.
Validation Async will make a request to the provided endpoint when the field loses focus. The response can contain an error message if the value is invalid, or optionally a confirmation message if it's valid.
import com.wise.dynamicflow.feature.ValidateAsync
import com.wise.dynamicflow.feature.builders.build
import com.wise.dynamicflow.misc.HttpMethod
import com.wise.dynamicflow.step.Step
import com.wise.dynamicflow.step.builders.build
val validationAsync = Step.build {
id = "validationAsync"
title = ""
schemas {
obj {
id = "#schema"
title = "Enter recipient account details"
properties {
string("iban") {
title = "IBAN"
validationAsync = ValidateAsync(param = "iban", method = HttpMethod.POST, url = "/validate")
}
}
}
}
layout {
form { schemaId = "#schema" }
}
}The server can respond with a 200 range response for a success, or a 422 for a validation failure.
// status 200
{ "message": "This is a valid IBAN" }
You can also validate the entire payload on submission and return error messages. These can be attached to a specific input, or apply globally.
import com.wise.dynamicflow.feature.ActionBehavior
import com.wise.dynamicflow.feature.builders.build
import com.wise.dynamicflow.misc.HttpMethod
import com.wise.dynamicflow.step.Step
import com.wise.dynamicflow.step.builders.build
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
val validationSubmission = Step.build {
id = "validationSubmission"
title = ""
schemas {
obj {
id = "#schema"
title = "Enter recipient account details"
properties {
string("iban") {
title = "IBAN"
}
}
}
}
layout {
form { schemaId = "#schema" }
button {
title = "Submit"
behavior = ActionBehavior.build {
action {
url = "/sign-up"
method = HttpMethod.POST
}
}
}
}
errors {
error = "This is a global error"
validation = buildJsonObject {
put("iban", "This is an invalid IBAN")
}
}
model = buildJsonObject { put("iban", "NL39ABNA9403840137") }
}Note - refresh-on-change can result in the form the user is on updating while they are using it. This disruption to the user experience is always best avoided, and so refresh-on-change, while a powerful tool, should be a last resort. Consider whether you could split your step into multiple to achieve the same effect.
We can update our step when the value of an input changes with Refresh on Change. This allows us to build experiences like Complex Selection without including all child schemas in the original step.
val addressCollection = Step.build {
id = "addressCollection"
title = ""
refreshUrl = "/address"
schemas {
obj {
id = "#address"
title = "Your address"
properties {
oneOf("country") {
title = "Country"
onChange = RefreshBehavior()
schemas {
const {
value("uk")
title = "United Kingdom"
media = Media.Avatar.build {
content { uri { uri = "urn:wise:currencies:gbp:image" } }
}
}
}
}
}
}
}
layout {
form {
schemaId = "#address"
}
}
}Sometimes a flow will need to take the user out of the flow to perform an action, and then bring them back.
We can use External to take the user out of the flow. In a browser, this opens a new tab. On mobile, this will open a different app.
val externalStepExample = Step.build {
id = "External"
title = ""
external {
url = "https://google.com"
}
layout {}
}We will likely want to bring the user back to the flow once they've completed some action elsewhere. We can do this using Link Handling.
Link handling specifies how mobile apps should match incoming universal links, and what Action should be performed when detected. On Web, this has no effect, and it is up to the external page to close the tab when the action has been completed, which would bring the user back into the flow.
linkHandlers = listOf(
LinkHandler(
regexPattern = """https://wise.com/success-callback.""",
behavior = ActionBehavior.build {
action = Action.build {
url = "/handle-success-callback"
}
}
),
LinkHandler(
regexPattern = """https://wise.com/failure-callback.""",
behavior = ActionBehavior.build {
action = Action.build {
url = "/handle-failure-callback"
}
}
)
)Sometimes when dealing with external integrations it can take time for state to be updated. For these integrations, we can use Polling which instructs the client to make periodic requests to a specified URL.
It is up to the endpoint being polled to progress the flow, by returning an Action, the next Step, or an Exit Response.
val pollingStepExample = Step.build {
id = "Polling"
title = "Polling Example"
polling {
url = "/polling/get-status?id=12345"
delay = 5
timeout = 60
maxAttempts = 100
onError {
behavior = ActionBehavior.build {
action {}
}
}
}
layout {}
}Often it is necessary to collect several data entries with a similar or identical structure.
You can build a repeatable experience using ArrayList Schemas which allows the user to input a variable number of similar items.
val repeatableStepExample = Step.build {
id = "Repeatable"
title = ""
schemas {
obj {
id = "#schema"
properties {
list("residency") {
title = "Tax residency"
addItemTitle = "Add a tax residency"
editItemTitle = "Edit a tax residency"
items = OneOfSchema.build {
schemas {
obj {
title = "United Kingdom"
summary = Summary.Provider(providesMedia = true)
media = Media.Avatar.build {
content { uri { uri = "urn:wise:countries:gb:image" } }
}
properties {
const("country") {
title = "United Kingdom"
value("UK")
summary = Summary.Provider(providesTitle = true)
}
}
}
obj {
title = "Spain"
summary = Summary.Provider(providesMedia = true)
media = Media.Avatar.build {
content { uri { uri = "urn:wise:countries:es:image" } }
}
properties {
const("country") {
title = "Spain"
value("ES")
summary = Summary.Provider(providesTitle = true)
}
string("taxNumber") {
title = "NIE Number"
summary = Summary.Provider(providesDescription = true)
}
}
}
}
}
}
}
}
}
layout {
form {
schemaId = "#schema"
}
}
model = encodeToJsonElement(
mapOf(
"residency" to listOf(
mapOf("country" to "UK"),
mapOf(
"country" to "ES",
"taxNumber" to "12345678"
)
)
)
)
}For some flows, it may be a better experience to collect this data across multiple steps. Using ReviewLayouts we can summarise data to make it easy to review, and use buttons to create and edit this data. These screens will submit and return the user to the original review screen showing the new information.
val reviewAddresses = Step.build {
id = "reviewAddresses"
title = "Review your existing addresses"
layout {
review {
title = "Home Address"
callToAction {
title = "Edit"
behavior = ActionBehavior.build {
action {
url = "/edit/home"
}
}
}
fields {
field {
label = "Line 1"
value = "123 Baker Street"
}
field {
label = "Postcode"
value = "N1 1AA"
}
field {
label = "City"
value = "London"
}
}
}
review {
title = "Work Address"
callToAction {
title = "Edit"
behavior = ActionBehavior.build {
action {
url = "/edit/work"
}
}
}
fields {
field {
label = "Line 1"
value = "123 Baker Street"
}
field {
label = "Postcode"
value = "N1 1AA"
}
field {
label = "City"
value = "London"
}
}
}
}
}It is a common pattern to occasionally show some content modally. There are two different ways to do this, depending on when and how the content of the modal needs to be generated.
If the content of the modal is known at the time the step is generated, Modal Behavior can be used. Like other Behaviors, it can be attached to interactive elements and when clicked the modal it contains is opened.
Inside modals, Dismiss Behavior can be used to close the modal from any interactive element too.
val idInformation = Step.build {
id = "idInformation"
title = "We need to do some checks"
layout {
paragraph {
text = "To open a balance we'll need you to provide us with a government-issued photo ID document."
}
button {
title = "Why do we need this?"
behavior = ModalBehavior.build {
title = "Why we need your ID document"
content {
paragraph {
text = "Due to government regulation, we need to make sure the details you gave us are correct. You can provide a passport, driver's license, or other government-issued photo ID card as long as it clearly shows your face, name, date of birth, and address."
}
button {
title = "Dismiss"
behavior = DismissBehavior()
}
}
}
}
}
}If the content of the modal isn't known at the time the step is generated (i.e., it is dependent on the submission value of the step it is shown over), Modal Response can be used. When clients receive this response type, they open the modal contained in the response as if it was a part of the current step.
import com.wise.dynamicflow.feature.ActionBehavior
import com.wise.dynamicflow.feature.builders.build
import com.wise.dynamicflow.misc.HttpMethod
import com.wise.dynamicflow.step.Step
import com.wise.dynamicflow.step.builders.build
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
val confirmationOfPayee = Step.build {
id = "confirmationOfPayee"
title = "Recipient Details"
schemas {
obj {
id = "#form"
properties {
string("name") {
title = "Name"
}
string("accountNumber") {
title = "Account Number"
}
string("sortCode") {
title = "Sort Code"
displayFormat = "**-**-**"
}
}
}
}
layout {
form {
schemaId = "#form"
}
button {
title = "Submit"
behavior = ActionBehavior.build {
action {
url = "/submit-modal"
method = HttpMethod.POST
}
}
}
}
model = buildJsonObject {
put("name", "John Smith")
put("accountNumber", "12345678")
put("sortCode", "000000")
}
}It doesn't always make sense to go back to the previous step which clicking or navigating back on mobile. There are two ways to manipulate this behavior.
Back behavior can be used to perform a specific action when navigating backwards.
navigation {
back {
title = "Back"
action {
url = "/payment-method-selection"
}
}
}A step can specify an operation to perform on the navigation stack when it loads. It can clear the stack, remove the previous item, replace the current, or by default, it will push the new screen.
There are many reasons to do this:
- Appear like the step is a 'refresh'
- Remove an intermediate step that displays a loader
- Clear the backstack (to prevent the user from returning to a steps that might be invalid if certain actions were taken)
navigation {
stackBehavior = Navigation.StackBehavior.REPLACE_CURRENT
}Sometimes, for payload size or security reasons, we need to submit data separately from the main form. This is common for files and sensitive information such as credit card numbers. In these cases we can use Persist Async, which enables values to be submitted to a specified URL, and return a token provided that will be included in the form submission in its place.
obj {
id = "#schema"
properties {
string("file") {
persistAsync {
url = "/upload"
method = HttpMethod.POST
param = "bodyAttribute"
idProperty = "token"
schema = BlobSchema.build {
source = Upload.Source.FILE
title = "Invoice"
description = "PNG, JPG, or PDF, less than 5mb"
accepts = listOf(
"image/png",
"image/jpg",
"application/pdf"
)
}
}
}
}
}Dynamic Flow is stateless. If you want to persist data across steps without using a session in your backend, you can include any extra data you want submitted in the step you send to the client.
The model property will pre-fill matching schema values into your step.
val filledDetails = Step.build {
id = "filledDetails"
title = "Are these details correct?"
schemas {
obj {
id = "#schema"
properties {
string("name") {
title = "Name"
}
string("phone") {
title = "Phone"
control = Control.Schema.String.PHONE_NUMBER
}
obj("address") {
title = "Address"
properties {
string("line1") {
title = "Line 1"
}
string("city") {
title = "City"
}
string("postcode") {
title = "Postcode"
}
}
}
}
}
}
model = encodeToJsonElement(
mapOf(
"name" to "John Smith",
"phone" to "+123456789",
"address" to mapOf(
"line1" to "123 Baker Street",
"city" to "London",
"postcode" to "N1 1AA"
)
)
)
layout {
form {
schemaId = "#schema"
}
}
}If you want to include data that isn't shown on the screen, you need a schema which is either hidden or unreferenced (not laid out on the screen in a FormLayout). You can then add the required data to the model. Showing and hiding schemas and gradually adding data is one way to build up a data model over multiple steps.
val contactDetailsExample = Step.build {
id = "contactDetails"
title = ""
schemas {
obj {
id = "#address"
title = "Address"
properties {
string("line1") {
title = "Address Line 1"
}
string("postcode") {
title = "Postcode"
}
}
}
obj {
id = "#contact"
title = "Contact details"
properties {
string("name") {
title = "Name"
hidden = true
}
string("phone") {
title = "Phone number"
control = Control.Schema.String.PHONE_NUMBER
}
string("email") {
title = "Email"
}
}
}
}
layout {
form {
schemaId = "#contact"
}
button {
title = "Continue"
behavior = ActionBehavior.build {
action {
url = "/submit"
}
}
}
}
model = encodeToJsonElement(
mapOf(
"name" to "John Smith",
"line1" to "123 Baker Street",
"postcode" to "N1 1AA"
)
)
}