Hello Form!¶
This tutorial will focus on how to easily create forms in the webclient with both backend and frontend validation. It will also guide you on how to show and hide specific fields that depend on another value in the form. Finally, we are also going to implement a custom component to be used for a specific field in the form to make it a little more unique.
Prerequisites¶
This tutorial assumes that there is already a project set up with lime-project
that has both custom endpoints and web components enabled. The tutorial will not focus on how to configure the web client to show the created components in a specific slot, please refer to the other guides if you need help with this.
Creating a schema¶
Let's start by defining a schema in Python in order to validate our data. Our form is going to handle orders at an online sushi restaurant where the user enters their name, how much money they have and what to include in the order. We define our schema using Marshmallow.
from marshmallow import (
Schema, fields, validate, validates_schema, ValidationError)
def create_schema():
class MealSchema(Schema):
name = fields.String(title='Name',
description='Enter your name',
required=True,
validate=validate.Length(5))
budget = fields.Integer(title='Budget',
description='How much cash do you have?',
required=True,
validate=validate.Range(10))
dishes = fields.List(fields.String(),
title='Dishes',
description='Each dish is €10',
validate=get_dishes())
dessert = fields.String(title='Dessert',
description='A dessert is €20',
validate=get_desserts())
@validates_schema
def validate_budget(self, data, **kwargs):
sum = 0
if 'dishes' in data:
sum += len(data['dishes']) * 10
if 'dessert' in data:
sum += 20
if sum > data['budget']:
raise ValidationError('Not enough cash!', 'budget')
class Meta:
ordered = True
return MealSchema()
def get_dishes():
choices = ['egg_nigiri', 'salmon_nigiri', 'squid_nigiri', 'maki_rolls',
'temaki', 'uramaki', 'sashimi', 'tempura']
labels = [choice.capitalize().replace('_', ' ') for choice in choices]
return validate.ContainsOnly(choices, labels)
def get_desserts():
choices = ['matcha_ice_cream', 'pudding', 'fruit_sallad']
labels = [choice.capitalize().replace('_', ' ') for choice in choices]
return validate.OneOf(choices, labels)
The schema has some basic validations like the length of the name and a requirement of at least €10 from the customer in order to place the order. The actual meals are validated using the validators returned from the get_dishes
and get_desserts
functions. We also have a custom validation method where we validate that the customer have enough cash in the validate_budget
function.
Creating a schema endpoint¶
In order for us to be able to create a form from this schema and validate the data on the frontend before it is being sent to the backend, we must convert the schema to a JSON schema and send it to the frontend. Let's create a custom endpoint for this.
import lime_webserver.webserver as webserver
from ..endpoints import api
from ..schema import create_schema
from lime_serializer import JSONSchema
class Schema(webserver.LimeResource):
def get(self):
"""Get the schema for the form
"""
schema = create_schema()
return JSONSchema().dump(schema)
api.add_resource(Schema, '/schema/')
As seen in the code above, converting the Marshmallow schema to a JSON schema is super simple and we can now use this schema on the frontend to generate a form for it.
Creating a web component with a form¶
Let's create a new web component where we use the limel-form
component from Lime Elements. This component will load the schema from our custom endpoint, feed it to the form component and submit the data to another endpoint when the data is valid.
import { ValidationStatus } from '@limetech/lime-elements';
import {
HttpClient,
LimeWebComponent,
LimeWebComponentContext,
LimeWebComponentPlatform,
PlatformServiceName,
} from '@limetech/lime-web-components';
import { Component, h, Prop, State } from '@stencil/core';
@Component({
tag: 'lwc-limepkg-hello-form-form',
shadow: true,
styleUrl: 'lwc-limepkg-hello-form-form.scss',
})
export class Form implements LimeWebComponent {
@Prop()
public platform: LimeWebComponentPlatform;
@Prop()
public context: LimeWebComponentContext;
@State()
private data: object;
@State()
private response: string = '';
@State()
private valid: boolean;
private schema: object;
private http: HttpClient;
public async componentWillLoad() {
this.http = this.platform.get(PlatformServiceName.Http);
this.schema = await this.http.get('limepkg-hello-form/schema/');
}
public render() {
return [
<limel-form
schema={this.schema}
value={this.data}
onChange={this.handleChange}
onValidate={this.handleValidate}
/>,
<limel-button
label="Submit"
disabled={!this.valid}
primary={true}
onClick={this.handleButtonClick}
/>,
<p>{this.response}</p>,
];
}
private handleChange = (event: CustomEvent<object>) => {
this.data = event.detail;
};
private handleValidate = (event: CustomEvent<ValidationStatus>) => {
this.valid = event.detail.valid;
};
private handleButtonClick = async () => {
try {
this.response = await this.http.post(
'limepkg-hello-form/form/',
this.data
);
} catch (e) {
this.response = Object.values(e.error).flat().join('\n');
}
};
}
There is quite a lot of code here, but it's not as complex as it first might look. Let's go through the most important parts.
- We declare 3 state variables
data
,valid
, andresponse
.data
will contain everything the user has entered in the form, whilevalid
will keep track whether the form is valid for submission or not.response
will contain the response from the server. - In
componentWillLoad
we load the schema from the endpoint and save it for thelimel-form
component. The URL for the endpoint might have to be changed to match your environment. - The
render
method renders our form together with a button to submit the data and a message with the response from the server - The form will emit 2 events,
change
andvalidate
. We listen for those two events inhandleChange
andhandleValidate
and update ourdata
andvalid
variables accordingly. - In
handleButtonClick
we take the entered data and send it to the server and save the response to be displayed under the form. In case of an error the error massages are displayed instead. Again, you might have to change the URL for the endpoint to match your environment.
The form should now render and you should be able test the validation logic that was declared in the schema. Once the form is valid, the submit button will be enabled and the form can be submitted.
Note
It is only possible to get basic validation declared in the Marshmallow schema serialized to the JSON schema. Any custom validation, like the budget validator in our example, will not be possible to run on the frontend. If you want the same validation on the frontend as well, you will have to reimplement that logic in the web component. That is out of scope for this tutorial and is often not needed.
Creating a form endpoint¶
The form submission will not work right now since there is still no endpoint on the backend to handle the form submission. Let's fix that by creating a new custom endpoint to handle the submitted data.
import lime_webserver.webserver as webserver
from webargs.flaskparser import parser
from ..endpoints import api
from ..schema import create_schema
from flask import request
from marshmallow import ValidationError
class Form(webserver.LimeResource):
def post(self):
"""Handle form submission
"""
schema = create_schema()
try:
data = parser.parse(schema, request)
print(data)
except ValidationError as err:
return err.messages, 400
return 'Your order has been placed!'
@parser.error_handler
def handle_error(error, *args, **kwargs):
raise error
api.add_resource(Form, '/form/')
This endpoint creates an instance of our schema and uses that with the parser
to parse the request to get the data from the form. In order to be able to send any validation errors back to the frontend we must implement our own error handler for the parser. This error handler just raises the error again so we can catch it in the endpoint and create a response out of it. The error response will contain an object with the same structure as the data, but with error messages for each field. If there are no validation errors, we just return a string with a status message.
Conditional fields¶
Let's improve our form by having a conditional field. We can add a checkbox for the user to include sprinkles for €5 if they have ordered the "Matcha ice cream" dessert. Our updated schema now looks like this (some of the old code was left out to focus only on what was added).
from lime_serializer.validate import Dependency
def create_schema():
class MealSchema(Schema):
# Other fields here were removed from example
#
sprinkles = fields.Boolean(title='Sprinkles for €5',
depends_on=Dependency('dessert', {
'matcha_ice_cream': None
}))
@validates_schema
def validate_budget(self, data, **kwargs):
sum = 0
if 'dishes' in data:
sum += len(data['dishes']) * 10
if 'dessert' in data:
sum += 20
if data['dessert'] == 'matcha_ice_cream' and data['sprinkles']:
sum += 5
if sum > data['budget']:
raise ValidationError('Not enough cash!', 'budget')
We have added a new field sprinkles
to the schema. As can be seen, this field has a dependency on the dessert
field. The second argument to the Dependency
constructor takes a dict
with all the valid values that this field is dependent on, and an optional validator that should be used when this values is set. In this case, the sprinkles
field requires that dessert
is set 'matcha_ice_cream'
. We do not want any additional validator when this value is set, so we just set it to None
.
The validate_budget
method has also been updated. If the dessert
is set to 'matcha_ice_cream'
and if sprinkles
is set to True
, we add another €5 to the total sum of the order.
If we reload the form with this new schema, the form should look exactly the same as before. However, when the value of "Dessert" is changed to "Matcha ice cream", the additional checkbox will be displayed.
Note
The ability to show and hide fields using dependencies is quite useful. However, it is quite limited and it's not possible to implement advanced dependency logic within the schema itself. E.g. it is only possible to depend on another value that is on the same level within the schema. It is also not possible to depend on several values and the values have to be discrete, e.g. you can not depend on a number being greater than some value.
If requirements like these exist, this logic will have to be implemented in a custom web component that is used within the form.
Creating a custom component¶
The form is now fully functional. However, we can improve it a bit to make it more fun to use by providing a custom component for one of the fields. Let's do that by switching out the "Dessert" dropdown with a button group instead.
Let's start by creating a web component that renders the buttons.
import { Button, FormComponent, FormInfo } from '@limetech/lime-elements';
import { Component, Event, EventEmitter, h, Prop } from '@stencil/core';
@Component({
tag: 'lwc-limepkg-hello-form-dessert-selector',
shadow: true,
})
export class DessertSelector implements FormComponent<string> {
@Prop()
public value: string;
@Prop()
public disabled: boolean;
@Prop()
public label: string;
@Prop()
public helperText: string;
@Prop()
public formInfo: FormInfo;
@Event()
public change: EventEmitter<string>;
public render() {
const schema: any = this.formInfo.schema;
const buttons = schema.oneOf.map(this.createButton);
return [
<div class="label">{this.label}</div>,
<limel-button-group
disabled={this.disabled}
value={buttons}
onChange={this.handleButtonChange}
/>,
<div class="helper-text">{this.helperText}</div>,
];
}
private handleButtonChange(event: CustomEvent<Button>) {
event.stopPropagation();
this.change.emit(event.detail.id);
}
private createButton = (schema: any): Button => {
return {
title: schema.title,
icon: this.getIcon(schema.const),
id: schema.const,
selected: schema.const === this.value,
};
};
private getIcon = (value: string): string => {
const icons = {
matcha_ice_cream: 'ice_cream_cone',
pudding: 'bluestacks',
fruit_sallad: 'citrus',
};
return icons[value];
};
}
All components that should be limel-form
compatible must implement the FormComponent
interface. This will allow the form to give the component all the needed properties that it requires. Not all properties are implemented in this example, the interface contains additional properties that can be added if needed.
The value
property will contain the current value of the field. In this case it will be a string since that is what we declared in the schema. The JSON schema is also available in the formInfo
property. We can use this to get all the valid values that can be selected. We use this to create the buttons in the createButton
method. Since this is just a basic example, it will fail to display an icon if the schema is updated with new desserts. New icons must then be added in the getIcon
method.
Whenever a button is clicked, the button group will fire a change
event with the corresponding button. We use this to emit our own change
event with the correct string
value for our field in handleButtonChange
.
The component is still not used within the form. In order to accomplish this we need to update our schema and give it additional metadata. We can do this under the lime
key for the field.
def create_schema():
dessert_component = {
'name': 'lwc-limepkg-hello-form-dessert-selector'
}
class MealSchema(Schema):
# Other fields here were removed from example
#
dessert = fields.String(title='Dessert',
description='A dessert is €20',
validate=get_desserts(),
lime={
'component': dessert_component
})
If we now reload the form, we should get a nice looking button group in order to select a dessert. The form should still function exactly like before.