diff --git a/docs/source/content/examples/overview.rst b/docs/source/content/examples/overview.rst index 13a1e78..82769d3 100644 --- a/docs/source/content/examples/overview.rst +++ b/docs/source/content/examples/overview.rst @@ -10,4 +10,5 @@ message interactive media + sign_up_flow template diff --git a/docs/source/content/examples/sign_up_flow.rst b/docs/source/content/examples/sign_up_flow.rst new file mode 100644 index 0000000..461f465 --- /dev/null +++ b/docs/source/content/examples/sign_up_flow.rst @@ -0,0 +1,1379 @@ +Sign Up Flow +============ + +.. currentmodule:: pywa.types.flows + +**In this example, we will create a sign up flow that allows users to sign up and login to their account.** + +The screens are the most important part of a flow. They are the building blocks of a flow. + +Each :class:`Screen` has + +- A ``id``: The unique ID of the screen, which is used to navigate to the screen +- A ``title``: The title of the screen, which is displayed at the top of the screen +- A ``layout``: The layout of the screen, which contains the elements that are displayed on the screen. + If we want to collect data from the user, we can use a form. +- A ``data``: The data that the screen expects to receive. This data is used to insert content and configure the screen. + + + +Start Screen +-------------- + +Let's start from the ``START`` screen. This screen welcomes the user and allows them to sign up or login. + +.. code-block:: python + :linenos: + :emphasize-lines: 6, 9, 26 + + + START = Screen( + id="START", + title="Home", + layout=Layout( + children=[ + TextHeading( + text="Welcome to our app", + ), + EmbeddedLink( + text="Click here to sign up", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="SIGN_UP", + ), + payload={ + "first_name_initial_value": "", + "last_name_initial_value": "", + "email_initial_value": "", + "password_initial_value": "", + "confirm_password_initial_value": "", + }, + ), + ), + EmbeddedLink( + text="Click here to login", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="LOGIN", + ), + payload={ + "email_initial_value": "", + "password_initial_value": "", + }, + ), + ), + ] + ), + ) + +The ``START`` screen has three elements: + +- A heading, which welcomes the user +- A link to the ``SIGN_UP`` screen, which allows the user to sign up +- A link to the ``LOGIN`` screen, which allows the user to login + +Each EmbeddedLink has an ``.on_click_action`` with Action value, so when the user clicks on the link, the action is triggered. In this case, +the action is to NAVIGATE to another screen. The payload contains the data that will be passed to the next screen. In +this case, we are passing empty strings as the initial values for the form fields on the SIGN_UP and LOGIN screens. +We will see how this works later on. + + +Sign Up Screen +-------------- + +The ``SIGN_UP`` screen allows the user to sign up. Let's take a look at the layout: + + +.. code-block:: python + :linenos: + :emphasize-lines: 4, 5, 6, 7, 8, 9, 10, 25, 26, 33, 38, 40, 45, 47, 52, 54, 62, 64, 71, 77, 78, 79, 80, 81, 82, 83 + + SIGN_UP = Screen( + id="SIGN_UP", + title="Sign Up", + data=[ + first_name_initial_value := ScreenData(key="first_name_initial_value", example="John"), + last_name_initial_value := ScreenData(key="last_name_initial_value", example="Doe"), + email_initial_value := ScreenData(key="email_initial_value", example="john.doe@gmail.com"), + password_initial_value := ScreenData(key="password_initial_value", example="abc123"), + confirm_password_initial_value := ScreenData(key="confirm_password_initial_value", example="abc123"), + ], + layout=Layout( + children=[ + TextHeading( + text="Please enter your details", + ), + EmbeddedLink( + text="Already have an account?", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="LOGIN", + ), + payload={ + "email_initial_value": FormRef("email"), + "password_initial_value": FormRef("password"), + }, + ), + ), + Form( + name="form", + children=[ + first_name := TextInput( + name="first_name", + label="First Name", + input_type=InputType.TEXT, + required=True, + init_value=first_name_initial_value.data_key, + ), + last_name := TextInput( + name="last_name", + label="Last Name", + input_type=InputType.TEXT, + required=True, + init_value=last_name_initial_value.data_key, + ), + email := TextInput( + name="email", + label="Email Address", + input_type=InputType.EMAIL, + required=True, + init_value=email_initial_value.data_key, + ), + password := TextInput( + name="password", + label="Password", + input_type=InputType.PASSWORD, + min_chars=8, + max_chars=16, + helper_text="Password must contain at least one number", + required=True, + init_value=password_initial_value.data_key, + ), + confirm_password := TextInput( + name="confirm_password", + label="Confirm Password", + input_type=InputType.PASSWORD, + min_chars=8, + max_chars=16, + required=True, + init_value=confirm_password_initial_value.data_key, + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.DATA_EXCHANGE, + payload={ + "first_name": first_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, + "password": password.form_ref, + "confirm_password": confirm_password.form_ref, + }, + ), + ), + ] + ) + ] + ) + ) + + +Ok, that's a lot of code. Let's break it down. + + In this examples we are using the walrus operator (:=) to assign values to variables. This allows us to use the + variables later on in the code without having to declare them outside of the layout and then assign them values later + +The ``SIGN_UP`` screen expects to receive some data. This data is used to populate the form fields with initial values. + +Every :class:`ScreenData` need to have a unique ``key`` and an ``example`` value. The example value is used to generate the appropriate +JSON schema for the data. Also, we are assigning every :class:`ScreenData` to a variable (inlined with the walrus operator) so +that we can use them later on in the code to reference the data (e.g. ``first_name_initial_value.data_key``). + +The layout of the ``SIGN_UP`` screen contains the following elements: + +- A :class:`TextHeading`, which tells the user to enter their details +- A :class:`EmbeddedLink` to the ``LOGIN`` screen, which allows the user to login if they already have an account (the user just remembered + that they already have an account) +- A :class:`Form`, which contains the form fields that the user needs to fill in to sign up +- A :class:`Footer`, which contains a button that the user can click to submit the form + +The form fields are: + +- A :class:`TextInput` field for the first name, which is required +- A :class:`TextInput` field for the last name, which is required +- A :class:`TextInput` field for the email address (the input type is set to :class:`InputType.EMAIL`, so that the keyboard on the user's phone + will show the ``@`` symbol and validate the email address. Also, the input is required) +- A :class:`TextInput` field for the password (the input type is set to :class:`InputType.PASSWORD`, so that the user's password is hidden when they type it) + We are also providing a helper text to tell the user that the password must contain at least one number. Also, the minimum number of characters is 8 and the maximum is 16, and the input is required) +- A :class:`TextInput` field for the confirm password (the input type is set to :class:`InputType.PASSWORD`, so that the user's password is hidden when they re-type it) + +Now, every form child get assigned to a variable (inlined with the walrus operator) so that we can use them later on in +the code to reference the form fields (e.g. ``first_name.form_ref``). + +The :class:`Footer` contains a button that the user can click to submit the form. When the user clicks on the button, the :class:`Action` +``DATA_EXCHANGE`` is triggered. This action allows us to send data to the server and then decide what to do next (for example, +if the user is already registered, we can navigate to the ``LOGIN`` screen, or if the password and confirm password do not match, +we can show an error message and ask the user to try again). + +The payload of the ``DATA_EXCHANGE`` action contains the data that we want to send to the server. In this case, we are sending +the values of the form fields. The values can be either a :class:`DataKey` or a :class:`Formref`. A `:class:`DataKey` is used to reference +a screen's ``.data``. and a :class:`FormRef`` is used to reference :class:`Form`'s ``.children``. +Because we are using the walrus operator to assign the form fields to variables, we can use the variables to reference the form fields +by using the the ``.form_ref`` property of the form field. + + We are not using the ``.form_ref`` property to reference the ``email`` and ``password`` fields in the :class:`EmbeddedLink` because + the ``.form_ref`` property is only available after the :class:`Form` has been added to the layout. So, we are using the :class:`FormRef` class + +Sign In Screen +-------------- + +Ok, now to the ``LOGIN`` screen. This screen allows the user to login to their existing account. Let's take a look at the layout: + + +.. code-block:: python + :linenos: + :emphasize-lines: 5, 6, 7, 8, 22, 23, 24, 25, 26, 27, 28, 34, 39, 41, 46, 52, 53, 54, 55 + + LOGIN = Screen( + id="LOGIN", + title="Login", + terminal=True, + data=[ + email_initial_value := ScreenData(key="email_initial_value", example="john.doe@gmail.com"), + password_initial_value := ScreenData(key="password_initial_value", example="abc123"), + ], + layout=Layout( + children=[ + TextHeading( + text="Please enter your details" + ), + EmbeddedLink( + text="Don't have an account?", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="SIGN_UP", + ), + payload={ + "email_initial_value": FormRef("email"), + "password_initial_value": FormRef("password"), + "confirm_password_initial_value": "", + "first_name_initial_value": "", + "last_name_initial_value": "", + }, + ), + ), + Form( + name="form", + children=[ + email := TextInput( + name="email", + label="Email Address", + input_type=InputType.EMAIL, + required=True, + init_value=email_initial_value.data_key, + ), + password := TextInput( + name="password", + label="Password", + input_type=InputType.PASSWORD, + required=True, + init_value=password_initial_value.data_key, + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.DATA_EXCHANGE, + payload={ + "email": email.form_ref, + "password": password.form_ref, + }, + ), + ), + ] + ) + ] + ) + ) + + +This screen is very straightforward. It has two form fields: + +- A :class:`TextInput` field for the email address (the input type is set to :class:`InputType.EMAIL`, so that the keyboard on the user's phone + will show the @ symbol and validate the email address) +- A :class:`TextInput` field for the password (the input type is set to :class:`InputType.PASSWORD`, so that the user's password is hidden when they type it) + +The :class:`Footer` contains a button that the user can click to submit the form. When the user clicks on the button, We are using the +``DATA_EXCHANGE`` action to send the email and password that the user entered, to the server and then decide what to do next (for example, +if the user is not registered, we can navigate to the ``SIGN_UP`` screen, or if the password is incorrect, we can show an error message and ask +the user to try again). + +Login Success Screen +-------------------- + +Now, to the last screen, the ``LOGIN_SUCCESS`` screen. This screen is displayed when the user successfully logs in: + +.. code-block:: python + :linenos: + :emphasize-lines: 16, 24, 25, 26 + + LOGIN_SUCCESS = Screen( + id="LOGIN_SUCCESS", + title="Success", + terminal=True, + layout=Layout( + children=[ + TextHeading( + text="Welcome to our store", + ), + TextSubheading( + text="You are now logged in", + ), + Form( + name="form", + children=[ + stay_logged_in := OptIn( + name="stay_logged_in", + label="Stay logged in", + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.COMPLETE, + payload={ + "stay_logged_in": stay_logged_in.form_ref, + }, + ), + ), + ] + ) + ] + ), + ) + +This screen has two elements: + +- A :class:`TextHeading`, which welcomes the user to the store +- A :class:`TextSubHeading`, which tells the user that they are now logged in +- A :class:`Form`, which contains an :class:`OptIn` field that asks the user if they want to stay logged in + +The :class:`Footer` contains a button that the user can click to submit the form. The ``COMPLETE`` action is used to complete the flow. +When the user clicks on the button, we are using the ``COMPLETE`` action to send the value of the :class:`OptIn` field to the server and +then complete the flow. + + +Creating the Flow +----------------- + +Now, we need to wrap everything in a :class:`FlowJSON` object and create the flow: + +.. code-block:: python + :linenos: + + from pywa import utils + from pywa.types.flows import FlowJSON + + SIGN_UP_FLOW_JSON = FlowJSON( + data_api_version=utils.Version.FLOW_DATA_API, + routing_model={ + "START": ["SIGN_UP", "LOGIN"], + "SIGN_UP": ["LOGIN"], + "LOGIN": ["LOGIN_SUCCESS"], + "LOGIN_SUCCESS": [], + }, + screens=[ + START, + SIGN_UP, + LOGIN, + LOGIN_SUCCESS, + ] + ) + + +The :class:`FlowJSON` object contains the following properties: + +- ``data_api_version``: The version of the data API that we are using. We are using the latest version, which is ``Version.FLOW_DATA_API`` +- ``routing_model``: The routing model of the flow. This is used to define the flow's navigation. In this case, we are using a simple routing model + that allows us to navigate from the ``START`` screen to the ``SIGN_UP`` and ``LOGIN`` screens, from the ``SIGN_UP`` screen to the ``LOGIN`` screen (and the other way around), + and from the ``LOGIN`` screen to the ``LOGIN_SUCCESS`` screen. The ``LOGIN_SUCCESS`` can't navigate to any other screen. +- ``screens``: The screens of the flow. In this case, we are using the screens that we created earlier. + +Here is all the flow code in one place: + +.. toggle:: + + .. code-block:: python + :linenos: + + from pywa import utils + from pywa.types.flows import ( + FlowJSON, + Screen, + ScreenData, + Form, + Footer, + Layout, + Action, + ActionNext, + ActionNextType, + FlowActionType, + FormRef, + InputType, + TextHeading, + TextSubheading, + TextInput, + OptIn, + EmbeddedLink, + ) + + SIGN_UP_FLOW_JSON = FlowJSON( + data_api_version=utils.Version.FLOW_DATA_API, + routing_model={ + "START": ["SIGN_UP", "LOGIN"], + "SIGN_UP": ["LOGIN"], + "LOGIN": ["LOGIN_SUCCESS"], + "LOGIN_SUCCESS": [], + }, + screens=[ + Screen( + id="START", + title="Home", + layout=Layout( + children=[ + TextHeading( + text="Welcome to our app", + ), + EmbeddedLink( + text="Click here to sign up", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="SIGN_UP", + ), + payload={ + "first_name_initial_value": "", + "last_name_initial_value": "", + "email_initial_value": "", + "password_initial_value": "", + "confirm_password_initial_value": "", + }, + ), + ), + EmbeddedLink( + text="Click here to login", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="LOGIN", + ), + payload={ + "email_initial_value": "", + "password_initial_value": "", + }, + ), + ), + ] + ), + ), + Screen( + id="SIGN_UP", + title="Sign Up", + data=[ + first_name_initial_value := ScreenData(key="first_name_initial_value", example="John"), + last_name_initial_value := ScreenData(key="last_name_initial_value", example="Doe"), + email_initial_value := ScreenData(key="email_initial_value", example="john.doe@gmail.com"), + password_initial_value := ScreenData(key="password_initial_value", example="abc123"), + confirm_password_initial_value := ScreenData(key="confirm_password_initial_value", example="abc123"), + ], + layout=Layout( + children=[ + TextHeading( + text="Please enter your details", + ), + EmbeddedLink( + text="Already have an account?", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="LOGIN", + ), + payload={ + "email_initial_value": FormRef("email"), + "password_initial_value": FormRef("password"), + }, + ), + ), + Form( + name="form", + children=[ + first_name := TextInput( + name="first_name", + label="First Name", + input_type=InputType.TEXT, + required=True, + init_value=first_name_initial_value.data_key, + ), + last_name := TextInput( + name="last_name", + label="Last Name", + input_type=InputType.TEXT, + required=True, + init_value=last_name_initial_value.data_key, + ), + email := TextInput( + name="email", + label="Email Address", + input_type=InputType.EMAIL, + required=True, + init_value=email_initial_value.data_key, + ), + password := TextInput( + name="password", + label="Password", + input_type=InputType.PASSWORD, + min_chars=8, + max_chars=16, + helper_text="Password must contain at least one number", + required=True, + init_value=password_initial_value.data_key, + ), + confirm_password := TextInput( + name="confirm_password", + label="Confirm Password", + input_type=InputType.PASSWORD, + min_chars=8, + max_chars=16, + required=True, + init_value=confirm_password_initial_value.data_key, + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.DATA_EXCHANGE, + payload={ + "first_name": first_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, + "password": password.form_ref, + "confirm_password": confirm_password.form_ref, + }, + ), + ), + ] + ) + ] + ) + ), + Screen( + id="LOGIN", + title="Login", + terminal=True, + data=[ + email_initial_value := ScreenData(key="email_initial_value", example="john.doe@gmail.com"), + password_initial_value := ScreenData(key="password_initial_value", example="abc123"), + ], + layout=Layout( + children=[ + TextHeading( + text="Please enter your details" + ), + EmbeddedLink( + text="Don't have an account?", + on_click_action=Action( + name=FlowActionType.NAVIGATE, + next=ActionNext( + type=ActionNextType.SCREEN, + name="SIGN_UP", + ), + payload={ + "email_initial_value": FormRef("email"), + "password_initial_value": FormRef("password"), + "confirm_password_initial_value": "", + "first_name_initial_value": "", + "last_name_initial_value": "", + }, + ), + ), + Form( + name="form", + children=[ + email := TextInput( + name="email", + label="Email Address", + input_type=InputType.EMAIL, + required=True, + init_value=email_initial_value.data_key, + ), + password := TextInput( + name="password", + label="Password", + input_type=InputType.PASSWORD, + required=True, + init_value=password_initial_value.data_key, + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.DATA_EXCHANGE, + payload={ + "email": email.form_ref, + "password": password.form_ref, + }, + ), + ), + ] + ) + ] + ), + ), + Screen( + id="LOGIN_SUCCESS", + title="Success", + terminal=True, + layout=Layout( + children=[ + TextHeading( + text="Welcome to our store", + ), + TextSubheading( + text="You are now logged in", + ), + Form( + name="form", + children=[ + stay_logged_in := OptIn( + name="stay_logged_in", + label="Stay logged in", + ), + Footer( + label="Done", + on_click_action=Action( + name=FlowActionType.COMPLETE, + payload={ + "stay_logged_in": stay_logged_in.form_ref, + }, + ), + ), + ] + ) + ] + ), + ) + ] + ) + +And if you want to go to the `WhatsApp Flows Playground `_ and see the flow in action, copy the equivalent JSON to the playground: + +.. toggle:: + + .. code-block:: json + :linenos: + + { + "version": "3.0", + "data_api_version": "3.0", + "routing_model": { + "START": [ + "SIGN_UP", + "LOGIN" + ], + "SIGN_UP": [ + "LOGIN" + ], + "LOGIN": [ + "LOGIN_SUCCESS" + ], + "LOGIN_SUCCESS": [] + }, + "screens": [ + { + "id": "START", + "title": "Home", + "layout": { + "type": "SingleColumnLayout", + "children": [ + { + "type": "TextHeading", + "text": "Welcome to our app" + }, + { + "type": "EmbeddedLink", + "text": "Click here to sign up", + "on-click-action": { + "name": "navigate", + "next": { + "type": "screen", + "name": "SIGN_UP" + }, + "payload": { + "first_name_initial_value": "", + "last_name_initial_value": "", + "email_initial_value": "", + "password_initial_value": "", + "confirm_password_initial_value": "" + } + } + }, + { + "type": "EmbeddedLink", + "text": "Click here to login", + "on-click-action": { + "name": "navigate", + "next": { + "type": "screen", + "name": "LOGIN" + }, + "payload": { + "email_initial_value": "", + "password_initial_value": "" + } + } + } + ] + } + }, + { + "id": "SIGN_UP", + "title": "Sign Up", + "data": { + "first_name_initial_value": { + "type": "string", + "__example__": "John" + }, + "last_name_initial_value": { + "type": "string", + "__example__": "Doe" + }, + "email_initial_value": { + "type": "string", + "__example__": "john.doe@gmail.com" + }, + "password_initial_value": { + "type": "string", + "__example__": "abc123" + }, + "confirm_password_initial_value": { + "type": "string", + "__example__": "abc123" + } + }, + "layout": { + "type": "SingleColumnLayout", + "children": [ + { + "type": "TextHeading", + "text": "Please enter your details" + }, + { + "type": "EmbeddedLink", + "text": "Already have an account?", + "on-click-action": { + "name": "navigate", + "next": { + "type": "screen", + "name": "LOGIN" + }, + "payload": { + "email_initial_value": "${form.email}", + "password_initial_value": "${form.password}" + } + } + }, + { + "type": "Form", + "name": "form", + "init-values": { + "first_name": "${data.first_name_initial_value}", + "last_name": "${data.last_name_initial_value}", + "email": "${data.email_initial_value}", + "password": "${data.password_initial_value}", + "confirm_password": "${data.confirm_password_initial_value}" + }, + "children": [ + { + "type": "TextInput", + "name": "first_name", + "label": "First Name", + "input-type": "text", + "required": true + }, + { + "type": "TextInput", + "name": "last_name", + "label": "Last Name", + "input-type": "text", + "required": true + }, + { + "type": "TextInput", + "name": "email", + "label": "Email Address", + "input-type": "email", + "required": true + }, + { + "type": "TextInput", + "name": "password", + "label": "Password", + "input-type": "password", + "required": true, + "min-chars": 8, + "max-chars": 16, + "helper-text": "Password must contain at least one number" + }, + { + "type": "TextInput", + "name": "confirm_password", + "label": "Confirm Password", + "input-type": "password", + "required": true, + "min-chars": 8, + "max-chars": 16 + }, + { + "type": "Footer", + "label": "Done", + "on-click-action": { + "name": "data_exchange", + "payload": { + "first_name": "${form.first_name}", + "last_name": "${form.last_name}", + "email": "${form.email}", + "password": "${form.password}", + "confirm_password": "${form.confirm_password}" + } + } + } + ] + } + ] + } + }, + { + "id": "LOGIN", + "title": "Login", + "data": { + "email_initial_value": { + "type": "string", + "__example__": "john.doe@gmail.com" + }, + "password_initial_value": { + "type": "string", + "__example__": "abc123" + } + }, + "terminal": true, + "layout": { + "type": "SingleColumnLayout", + "children": [ + { + "type": "TextHeading", + "text": "Please enter your details" + }, + { + "type": "EmbeddedLink", + "text": "Don't have an account?", + "on-click-action": { + "name": "navigate", + "next": { + "type": "screen", + "name": "SIGN_UP" + }, + "payload": { + "email_initial_value": "${form.email}", + "password_initial_value": "${form.password}", + "confirm_password_initial_value": "", + "first_name_initial_value": "", + "last_name_initial_value": "" + } + } + }, + { + "type": "Form", + "name": "form", + "init-values": { + "email": "${data.email_initial_value}", + "password": "${data.password_initial_value}" + }, + "children": [ + { + "type": "TextInput", + "name": "email", + "label": "Email Address", + "input-type": "email", + "required": true + }, + { + "type": "TextInput", + "name": "password", + "label": "Password", + "input-type": "password", + "required": true + }, + { + "type": "Footer", + "label": "Done", + "on-click-action": { + "name": "data_exchange", + "payload": { + "email": "${form.email}", + "password": "${form.password}" + } + } + } + ] + } + ] + } + }, + { + "id": "LOGIN_SUCCESS", + "title": "Success", + "terminal": true, + "layout": { + "type": "SingleColumnLayout", + "children": [ + { + "type": "TextHeading", + "text": "Welcome to our store" + }, + { + "type": "TextSubheading", + "text": "You are now logged in" + }, + { + "type": "Form", + "name": "form", + "children": [ + { + "type": "OptIn", + "name": "stay_logged_in", + "label": "Stay logged in" + }, + { + "type": "Footer", + "label": "Done", + "on-click-action": { + "name": "complete", + "payload": { + "stay_logged_in": "${form.stay_logged_in}" + } + } + } + ] + } + ] + } + } + ] + } + + + + +Creating the flow is very simple: + +.. code-block:: python + :linenos: + + from pywa import WhatsApp + from pywa.types.flows import FlowCategory + + wa = WhatsApp( + phone_id="1234567890", + token="abcdefg", + business_account_id="1234567890", + ) + + flow_id = wa.create_flow( + name="Sign Up Flow", + categories=[FlowCategory.SIGN_IN, FlowCategory.SIGN_UP], + ) + +Because we are going to exchange data with our server, we need to provide endpoint URI for the flow. This is the URI that +WhatsApp will use to send data to our server. We can do this by updating the flow's metadata: + +.. code-block:: python + :linenos: + + wa.update_flow_metadata( + flow_id=flow_id, + endpoint_uri="https://my-server.com/sign-up-flow", + ) + +This endpoint must, of course, be pointing to our server. We can use ngrok or a similar tool to expose our server to the internet. + +Finally, let's update the flow's JSON: + +.. code-block:: python + :linenos: + + from pywa.errors import FlowUpdatingError + + try: + wa.update_flow_json( + flow_id=flow_id, + flow_json=SIGN_UP_FLOW_JSON, + ) + print("Flow updated successfully") + except FlowUpdatingError as e: + print(wa.get_flow_json(flow_id=flow_id).validation_errors) + +Storing Users +------------ + +After the flow updates successfully, we can start with our server logic. First we need a simple user repository to store the users: + +.. code-block:: python + :linenos: + + import typing + + class UserRepository: + def __init__(self): + self._users = {} + + def create(self, email: str, details: dict[str, typing.Any]): + self._users[email] = details + + def get(self, email: str) -> dict[str, typing.Any] | None: + return self._users.get(email) + + def update(self, email: str, details: dict[str, typing.Any]): + self._users[email] = details + + def delete(self, email: str): + del self._users[email] + + def exists(self, email: str) -> bool: + return email in self._users + + def is_password_valid(self, email: str, password: str) -> bool: + return self._users[email]["password"] == password + + user_repository = UserRepository() # create an instance of the user repository + + +Of course, in a real application, we would use a real database to store the users (and we never store the passwords in plain text...). + +Send the Flow +------------- + +Now, let's create a Flask app and a WhatsApp instance: + +.. code-block:: python + :linenos: + + import flask + from pywa import WhatsApp + + flask_app = flask.Flask(__name__) + + wa = WhatsApp( + phone_id="1234567890", + token="abcdefg", + server=flask_app, + callback_url="https://my-server.com", + webhook_endpoint="/webhook", + verify_token="xyz123", + app_id=123, + app_secret="zzz", + business_private_key=open("private.pem").read(), + business_private_key_password="abc123", + ) + + +The ``WhatsApp`` class takes a few parameters: + +- ``phone_id``: The phone ID of the WhatsApp account that we are using to send and receive messages +- ``token``: The token of the WhatsApp account that we are using to send and receive messages +- ``server``: The Flask app that we created earlier, which will be used to register the routes +- ``callback_url``: The URL that WhatsApp will use to send us updates +- ``webhook_endpoint``: The endpoint that WhatsApp will use to send us updates +- ``verify_token``: Used by WhatsApp to challenge the server when we register the webhook +- ``app_id``: The ID of the WhatsApp App, needed to register the callback URL +- ``app_secret``: The secret of the WhatsApp App, needed to register the callback URL +- ``business_private_key``: The private key of the WhatsApp Business Account, needed to decrypt the flow requests +- ``business_private_key_password``: The passphrase of the private_key, if it has one + + +First let's send the flow! + +.. code-block:: python + :linenos: + + from pywa.types import FlowButton + from pywa.types.flows import FlowStatus, FlowActionType + + wa.send_message( + phone_number="1234567890", + buttons=[ + FlowButton( + title="Sign Up", + flow_id=flow_id, + flow_token="5749d4f8-4b74-464a-8405-c26b7770cc8c", + mode=FlowStatus.DRAFT, + flow_action_type=FlowActionType.NAVIGATE, + flow_action_screen="START", + ), + ], + ) + +Ok, let's break this down: + +Sending a flow is very simple. We sending text (or image, video etc.) message with a FlowButton. The FlowButton contains the following properties: + +- ``title``: The title of the button (the text that the user will see on the button) +- ``flow_id``: The ID of the flow that we want to send +- ``mode``: The mode of the flow. We are using ``FlowStatus.DRAFT`` because we are still testing the flow. When we are ready to publish the flow, we can change the mode to ``FlowStatus.PUBLISHED`` +- ``flow_action_type``: The action that will be triggered when the user clicks on the button. In this case, we are using ``FlowActionType.NAVIGATE`` to navigate to the ``START`` screen +- ``flow_action_screen``: The name of the screen that we want to navigate to. In this case, we are using ``START`` + +- ``flow_token``: The unique token for this specific flow. + +When the flow request is sent to our server, we don't know which flow and which user the request is for. We only know the flow token. +So, the flow token is used to give us some context about the flow request. We can use the flow token to identify the user and the flow. +The flow token can be saved in a database or in-memory cache, and be mapped to the user ID and the flow ID (in cases you have multiple flows running at your application). +And when requests are coming, you can use the flow token to identify the user and the flow and make the appropriate actions for the request. + + The flow token can be also used to invalidate the flow, by raising FlowTokenNoLongerValid exception with appropriate error_message. + +A good practice is to generate a unique token for each flow request. This way, we can be sure that the token is unique and that we can identify the user and the flow. +You can use the ``uuid`` module to generate a unique token: + +.. code-block:: python + :linenos: + + import uuid + + flow_token = str(uuid.uuid4()) + + +After we create the WhatsApp instance and we send the flow, we can start listening to flow requests: + +.. code-block:: python + :linenos: + + from pywa.types.flows import FlowRequest, FlowResponse + + @wa.on_flow_request("/sign-up-flow") + def on_sign_up_request(_: WhatsApp, flow: FlowRequest) -> FlowResponse | None: + if flow.has_error: + logging.error("Flow request has error: %s", flow.data) + return + + ... + + +The ``on_flow_request`` decorator takes the endpoint URI as a parameter. This is the endpoint that we provided when we updated the flow's metadata. +So if the endpoint URI is ``https://my-server.com/sign-up-flow``, then the endpoint URI that we are listening to is ``/sign-up-flow``. + + Yes, you can point multiple flows to the same endpoint URI. But then you need to find a way to identify the flow by the flow token. + I recommend creating a unique endpoint URI for each flow. + + + +The ``on_sign_up_request`` function takes two parameters: + +- ``wa``: The WhatsApp instance +- ``flow``: A :class:`FlowRequest` object, which contains the flow request data + +The flow request contains the following properties: + +- ``version``: The version of the flow data API that the flow request is using. +- ``flow_token``: The token of the flow (the same token that we provided when we sent the flow) +- ``action``: The action type that was triggered the request. ``FlowActionType.DATA_EXCHANGE`` in our case. +- ``screen``: The name of the screen that the user is currently on (We have two screens with data exchange actions, so we need to know which screen the user is currently on) +- ``data``: The data that the action sent to the server (the ``payload`` property of the action) + + +In the top of the function, we are checking if the flow request has an error. If it does, we are logging the error and returning. + + By default, if the flow has error, ``pywa`` will ignore the callback return value and will acknowledge the error. + This behavior can be changed by setting ``acknowledge_errors`` parameter to ``False`` in ``on_flow_request`` decorator. + +Handling Sign Up Flow Requests +------------------------------ + +Now, let's handle the flow request. we can handle all the screens in one code block but for the sake of simplicity, we will handle each screen separately: + +.. code-block:: python + :linenos: + + def handle_signup_screen(request: FlowRequest) -> FlowResponse: + + if user_repository.exists(request.data["email"]): + return FlowResponse( + version=request.version, + screen="LOGIN", + error_message="You are already registered. Please login", + data={ + "email_initial_value": request.data["email"], + "password_initial_value": request.data["password"], + }, + ) + elif request.data["password"] != request.data["confirm_password"]: + return FlowResponse( + version=request.version, + screen=request.screen, + error_message="Passwords do not match", + data={ + "first_name_initial_value": request.data["first_name"], + "last_name_initial_value": request.data["last_name"], + "email_initial_value": request.data["email"], + "password_initial_value": "", + "confirm_password_initial_value": "", + }, + ) + elif not any(char.isdigit() for char in request.data["password"]): + return FlowResponse( + version=request.version, + screen=request.screen, + error_message="Password must contain at least one number", + data={ + "first_name_initial_value": request.data["first_name"], + "last_name_initial_value": request.data["last_name"], + "email_initial_value": request.data["email"], + "password_initial_value": "", + "confirm_password_initial_value": "", + }, + ) + else: + user_repository.create(request.data["email"], request.data) + return FlowResponse( + version=request.version, + screen="LOGIN", + data={ + "email_initial_value": request.data["email"], + "password_initial_value": "", + }, + ) + +So, what's going on here? + +This function handles the ``SIGN_UP`` screen. +We need to do three things: + +- Check if the user is already registered. If they are, we need to navigate to the ``LOGIN`` screen and show an error message +- Check if the password and confirm password match. If they don't, we navigate again to ``SIGN_UP`` screen and show an error message +- Check if the password contains at least one number. If it doesn't, we navigate again to ``SIGN_UP`` screen and show an error message +- If everything is ok, we create the user and navigate to the ``LOGIN`` screen (with the email address already filled in 😋) + + Now you understand why ``SIGN_UP`` screen get's initial values? because we don't want the user to re-enter the data again if there is an error. + From the same reason, ``LOGIN`` screen get's initial values too, so when the sign up succeeds, the user will be navigated to the ``LOGIN`` screen with the email address already filled in. + +Handling Login Flow Requests +---------------------------- + +Now, let's handle the ``LOGIN`` screen: + +.. code-block:: python + :linenos: + + def handle_login_screen(request: FlowRequest) -> FlowResponse: + + if not user_repository.exists(request.flow_token): + return FlowResponse( + version=request.version, + screen="SIGN_UP", + error_message="You are not registered. Please sign up", + data={ + "first_name_initial_value": "", + "last_name_initial_value": "", + "email_initial_value": request.data["email"], + "password_initial_value": "", + "confirm_password_initial_value": "", + }, + ) + elif not user_repository.is_password_valid(request.flow_token, request.data["password"]): + return FlowResponse( + version=request.version, + screen=request.screen, + error_message="Incorrect password", + data={ + "email_initial_value": request.data["email"], + "password_initial_value": "", + }, + ) + else: + return FlowResponse( + version=request.version, + screen="LOGIN_SUCCESS", + data={}, + ) + +The ``LOGIN`` screen is very similar to the ``SIGN_UP`` screen. We need to do two things: + +- Check if the user is registered. If they are not, we need to navigate to the ``SIGN_UP`` screen and show an error message +- Check if the password is correct. If it's not, we need to navigate again to ``LOGIN`` screen and show an error message +- If everything is ok, we navigate to the ``LOGIN_SUCCESS`` screen + +Handling the Flow Requests +-------------------------- + +Let's call the functions that we created earlier: + +.. code-block:: python + :linenos: + + @wa.on_flow_request("/sign-up-flow") + def on_sign_up_request(_: WhatsApp, flow: FlowRequest) -> FlowResponse | None: + if flow.has_error: + logging.error("Flow request has error: %s", flow.data) + return + + if flow.screen == "SIGN_UP": + return handle_signup_screen(flow) + elif flow.screen == "LOGIN": + return handle_login_screen(flow) + + +Handling Flow Completion +------------------------ + +The ``LOGIN_SUCCESS`` scrren completes the flow, so we don't need to do anything here. instead we need to handle the flow completion: + +.. code-block:: python + :linenos: + + @wa.on_flow_completion() + def handle_flow_completion(_: WhatsApp, flow: FlowCompletion): + print("Flow completed successfully") + print(flow.token) + print(flow.payload) + +Now, in a real application, this is the time to mark the user as logged in and allow them to perform actions in their account. +You can also implement some kind of session management, so that the user will stay logged in for a certain amount of time and then require them to login again. + +Running the Server +------------------ + +The last thing that we need to do is run the server: + +.. code-block:: python + :linenos: + + if __name__ == "__main__": + flask_app.run() + +What's Next? +------------ + +Now that you know how to create and send a flow, you can try to add the following features to the flow: + +- A ``FORGOT_PASSWORD`` screen, which allows the user to reset their password if they forgot it +- A more detailed ``LOGIN_SUCCESS`` screen, which shows the user's name, email address and other details +- Try to adding a nice image to the ``START`` screen, to make it more appealing +- A ``LOGOUT`` screen, which allows the user to logout from their account +- Allow the user to change their email & password +- Allow the user to close the flow at any screen diff --git a/docs/source/content/flows/flow_json.rst b/docs/source/content/flows/flow_json.rst index 79a37c7..ef27643 100644 --- a/docs/source/content/flows/flow_json.rst +++ b/docs/source/content/flows/flow_json.rst @@ -9,6 +9,8 @@ Here you will find all the components that make up a Flow JSON object. .. autoclass:: Screen() +.. autoclass:: ScreenData() + .. autoclass:: Layout() .. autoclass:: LayoutType() diff --git a/docs/source/content/flows/overview.rst b/docs/source/content/flows/overview.rst index 4d499b5..7e10a1e 100644 --- a/docs/source/content/flows/overview.rst +++ b/docs/source/content/flows/overview.rst @@ -18,6 +18,8 @@ From `developers.facebook.com `_. + The Flows are seperated to 4 parts: - Creating Flow @@ -107,19 +109,19 @@ here is an example of static flow: Form( name="form", children=[ - TextInput( + first_name := TextInput( name="first_name", label="First Name", input_type=InputType.TEXT, required=True, ), - TextInput( + last_name := TextInput( name="last_name", label="Last Name", input_type=InputType.TEXT, required=True, ), - TextInput( + email := TextInput( name="email", label="Email Address", input_type=InputType.EMAIL, @@ -131,9 +133,9 @@ here is an example of static flow: on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "first_name": FormRef("first_name"), - "last_name": FormRef("last_name"), - "email": FormRef("email"), + "first_name": first_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, }, ), ), @@ -145,6 +147,7 @@ here is an example of static flow: ], ) + Which is the equivalent of the following flow json: .. toggle:: @@ -216,7 +219,8 @@ Here is example of dynamic flow: .. code-block:: python :caption: support_request.json :linenos: - :emphasize-lines: 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 34, 40, 46 + :emphasize-lines: 2, 3, 9, 10, 11, 12, 13, 25, 31, 37 + dynamic_flow = FlowJSON( data_api_version=utils.Version.FLOW_DATA_API, @@ -226,53 +230,44 @@ Here is example of dynamic flow: id="SIGN_UP", title="Finish Sign Up", terminal=True, - data={ - "first_name_helper_text": { - "type": "string", - "__example__": "Enter your first name", - }, - "is_last_name_required": { - "type": "boolean", - "__example__": True, - }, - "is_email_enabled": { - "type": "boolean", - "__example__": False, - }, - }, + data=[ + first_name_helper_text := ScreenData(key="first_name_helper_text", example="Enter your first name"), + is_last_name_required := ScreenData(key="is_last_name_required", example=True), + is_email_enabled := ScreenData(key="is_email_enabled", example=False), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="form", children=[ - TextInput( + first_name := TextInput( name="first_name", label="First Name", input_type=InputType.TEXT, required=True, - helper_text=DataKey("first_name_helper_text"), + helper_text=first_name_helper_text.data_key, ), - TextInput( + last_name := TextInput( name="last_name", label="Last Name", input_type=InputType.TEXT, - required=DataKey("is_last_name_required"), + required=is_last_name_required.data_key, ), - TextInput( + email := TextInput( name="email", label="Email Address", input_type=InputType.EMAIL, - enabled=DataKey("is_email_enabled"), + enabled=is_email_enabled.data_key, ), Footer( label="Done", on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "first_name": FormRef("first_name"), - "last_name": FormRef("last_name"), - "email": FormRef("email"), + "first_name": last_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, }, ), ), @@ -284,6 +279,7 @@ Here is example of dynamic flow: ], ) + Which is the equivalent of the following flow json: .. toggle:: @@ -531,26 +527,17 @@ So in our dynamic example (``dynamic_flow``) we have just one screen: ``SIGN_UP` .. code-block:: python :linenos: - :emphasize-lines: 4, 6, 10, 14 + :emphasize-lines: 6,7,8 Screen( id="SIGN_UP", title="Finish Sign Up", terminal=True, - data={ - "first_name_helper_text": { - "type": "string", - "__example__": "Enter your first name", - }, - "is_last_name_required": { - "type": "boolean", - "__example__": True, - }, - "is_email_enabled": { - "type": "boolean", - "__example__": False, - }, - }, + data=[ + first_name_helper_text := ScreenData(key="first_name_helper_text", example="Enter your first name"), + is_last_name_required := ScreenData(key="is_last_name_required", example=True), + is_email_enabled := ScreenData(key="is_email_enabled", example=False), + ], ... ) @@ -648,394 +635,7 @@ In our example, we returning our dynamic data to the ``SIGN_UP`` screen. Of course, it can be more complex, if you have multiple screens, you can return data from them and then decide what screen to open next or complete the flow. -Here is an example of a more complex flow and how to handle it (Do not use this code, it's just for demonstration): - -.. toggle:: - - .. code-block:: python - :linenos: - - import logging - import typing - import flask - from pywa import WhatsApp, utils - from pywa.types import FlowRequest, FlowResponse, FlowJSON - from pywa.types.flows import Screen, TextHeading, Layout, EmbeddedLink, Action, FlowActionType, ActionNext, \ - ActionNextType, Form, DataKey, FormRef, TextInput, InputType, TextSubheading, OptIn, Footer, FlowCategory - - - SIGN_UP_FLOW_JSON = FlowJSON( - data_api_version=utils.Version.FLOW_DATA_API, - routing_model={ - "START": ["SIGN_UP", "LOGIN"], - "SIGN_UP": ["LOGIN"], - "LOGIN": ["LOGIN_SUCCESS"], - "LOGIN_SUCCESS": [], - }, - screens=[ - Screen( - id="START", - title="Home", - layout=Layout( - children=[ - TextHeading( - text="Welcome to our app", - ), - EmbeddedLink( - text="Click here to sign up", - on_click_action=Action( - name=FlowActionType.NAVIGATE, - next=ActionNext( - type=ActionNextType.SCREEN, - name="SIGN_UP", - ), - payload={ - "first_name_initial_value": "", - "last_name_initial_value": "", - "email_initial_value": "", - "password_initial_value": "", - "confirm_password_initial_value": "", - }, - ), - ), - EmbeddedLink( - text="Click here to login", - on_click_action=Action( - name=FlowActionType.NAVIGATE, - next=ActionNext( - type=ActionNextType.SCREEN, - name="LOGIN", - ), - payload={ - "email_initial_value": "", - "password_initial_value": "", - }, - ), - ), - ] - ), - ), - Screen( - id="SIGN_UP", - title="Sign Up", - data={ - "first_name_initial_value": { - "type": "string", - "__example__": "John", - }, - "last_name_initial_value": { - "type": "string", - "__example__": "Doe", - }, - "email_initial_value": { - "type": "string", - "__example__": "john@gmail.com" - }, - "password_initial_value": { - "type": "string", - "__example__": "abc123" - }, - "confirm_password_initial_value": { - "type": "string", - "__example__": "abc123" - }, - }, - layout=Layout( - children=[ - TextHeading( - text="Please enter your details", - ), - Form( - name="form", - init_values={ - "first_name": DataKey("first_name_initial_value"), - "last_name": DataKey("last_name_initial_value"), - "email": DataKey("email_initial_value"), - "password": DataKey("password_initial_value"), - "confirm_password": DataKey("confirm_password_initial_value"), - }, - children=[ - EmbeddedLink( - text="Already have an account?", - on_click_action=Action( - name=FlowActionType.NAVIGATE, - next=ActionNext( - type=ActionNextType.SCREEN, - name="LOGIN", - ), - payload={ - "email_initial_value": FormRef("email"), - "password_initial_value": FormRef("password"), - }, - ), - ), - TextInput( - name="first_name", - label="First Name", - input_type=InputType.TEXT, - required=True, - ), - TextInput( - name="last_name", - label="Last Name", - input_type=InputType.TEXT, - required=True, - ), - TextInput( - name="email", - label="Email Address", - input_type=InputType.EMAIL, - required=True, - ), - TextInput( - name="password", - label="Password", - input_type=InputType.PASSWORD, - min_chars=8, - max_chars=16, - helper_text="Password must contain at least one number", - required=True, - ), - TextInput( - name="confirm_password", - label="Confirm Password", - input_type=InputType.PASSWORD, - min_chars=8, - max_chars=16, - required=True, - ), - Footer( - label="Done", - on_click_action=Action( - name=FlowActionType.DATA_EXCHANGE, - payload={ - "first_name": FormRef("first_name"), - "last_name": FormRef("last_name"), - "email": FormRef("email"), - "password": FormRef("password"), - "confirm_password": FormRef("confirm_password"), - }, - ), - ), - ] - ) - ] - ) - ), - Screen( - id="LOGIN", - title="Login", - terminal=True, - data={ - "email_initial_value": { - "type": "string", - "__example__": "john@gmail.com" - }, - "password_initial_value": { - "type": "string", - "__example__": "abc123" - }, - }, - layout=Layout( - children=[ - TextHeading( - text="Please enter your details" - ), - Form( - name="form", - init_values={ - "email": DataKey("email_initial_value"), - "password": DataKey("password_initial_value"), - }, - children=[ - EmbeddedLink( - text="Don't have an account?", - on_click_action=Action( - name=FlowActionType.NAVIGATE, - next=ActionNext( - type=ActionNextType.SCREEN, - name="SIGN_UP", - ), - payload={ - "email_initial_value": FormRef("email"), - "password_initial_value": FormRef("password"), - "confirm_password_initial_value": "", - "first_name_initial_value": "", - "last_name_initial_value": "", - }, - ), - ), - TextInput( - name="email", - label="Email Address", - input_type=InputType.EMAIL, - required=True, - ), - TextInput( - name="password", - label="Password", - input_type=InputType.PASSWORD, - required=True, - ), - Footer( - label="Done", - on_click_action=Action( - name=FlowActionType.DATA_EXCHANGE, - payload={ - "email": FormRef("email"), - "password": FormRef("password"), - }, - ), - ), - ] - ) - ] - ), - ), - Screen( - id="LOGIN_SUCCESS", - title="Success", - terminal=True, - layout=Layout( - children=[ - TextHeading( - text="Welcome to our app", - ), - TextSubheading( - text="You are now logged in", - ), - Form( - name="form", - children=[ - OptIn( - name="stay_logged_in", - label="Stay logged in", - ), - Footer( - label="Done", - on_click_action=Action( - name=FlowActionType.COMPLETE, - payload={ - "stay_logged_in": FormRef("stay_logged_in"), - }, - ), - ), - ] - ) - ] - ), - ), - ] - ) - - - class UserRepository: - def __init__(self): - self._users = {} - - def create(self, user_id: str, details: dict[str, typing.Any]): - self._users[user_id] = details - - def get(self, user_id: str) -> dict[str, typing.Any] | None: - return self._users.get(user_id) - - def update(self, user_id: str, details: dict[str, typing.Any]): - self._users[user_id] = details - - def delete(self, user_id: str): - del self._users[user_id] - - def exists(self, user_id: str) -> bool: - return user_id in self._users - - def is_password_valid(self, user_id: str, password: str) -> bool: - return self._users[user_id]["password"] == password - - - flask_app = flask.Flask(__name__) - user_repository = UserRepository() - - wa = WhatsApp( - phone_id="1234567890", - token="abcdefg", - server=flask_app, - callback_url="https://my-server.com", - webhook_endpoint="/webhook", - verify_token="xyz123", - app_id=123, - app_secret="zzz", - business_private_key=open("private.pem").read(), - business_private_key_password="abc123", - ) - - # RUN THIS ONLY ONCE TO CREATE AND UPDATE THE FLOW - flow_id = wa.create_flow( name="Sign Up Flow", categories=[FlowCategory.SIGN_IN, FlowCategory.SIGN_UP]) - wa.update_flow_json(flow_id, SIGN_UP_FLOW_JSON) - wa.update_flow_metadata(flow_id, endpoint_uri="https://my-server.com/flow") - - - @wa.on_flow_request("/flow") - def handle_sign_up_request(_: WhatsApp, flow: FlowRequest) -> FlowResponse | None: - if flow.has_error: - logging.error(flow.data) - return - - match flow.screen: - case "SIGN_UP": - if flow.data["password"] != flow.data["confirm_password"]: - return FlowResponse( - version=flow.version, - screen=flow.screen, - error_message="Passwords do not match", - data={ - "first_name_initial_value": flow.data["first_name"], - "last_name_initial_value": flow.data["last_name"], - "email_initial_value": flow.data["email"], - "password_initial_value": "", - "confirm_password_initial_value": "", - }, - ) - else: - user_repository.create(flow.flow_token, flow.data) - return FlowResponse( - version=flow.version, - screen="LOGIN", - data={ - "email_initial_value": flow.data["email"], - "password_initial_value": "", - }, - ) - case "LOGIN": - if not user_repository.exists(flow.flow_token): - return FlowResponse( - version=flow.version, - screen="SIGN_UP", - error_message="You are not registered. Please sign up", - data={ - "first_name_initial_value": "", - "last_name_initial_value": "", - "email_initial_value": flow.data["email"], - "password_initial_value": "", - "confirm_password_initial_value": "", - }, - ) - elif not user_repository.is_password_valid(flow.flow_token, flow.data["password"]): - return FlowResponse( - version=flow.version, - screen=flow.screen, - error_message="Incorrect password", - data={ - "email_initial_value": flow.data["email"], - "password_initial_value": "", - }, - ) - else: - return FlowResponse( - version=flow.version, - screen="LOGIN_SUCCESS", - data={}, - ) - +If you want example of more complex flow, you can check out the `Sign up Flow Exmaple <../examples/sign_up_flow.html>`_. Getting Flow Completion message ------------------------------- diff --git a/pywa/types/flows.py b/pywa/types/flows.py index ce9a7ca..4d3cd53 100644 --- a/pywa/types/flows.py +++ b/pywa/types/flows.py @@ -38,6 +38,7 @@ "FlowAsset", "FlowJSON", "Screen", + "ScreenData", "Layout", "LayoutType", "Form", @@ -283,6 +284,11 @@ class FlowTokenNoLongerValid(FlowResponseError): """ This exception need to be returned or raised from the flow endpoint callback when the Flow token is no longer valid. + Example: + + >>> from pywa.types.flows import FlowTokenNoLongerValid + >>> raise FlowTokenNoLongerValid(error_message='The order has already been placed') + - The layout will be closed and the ``FlowButton`` will be disabled for the user. You can send a new message to the user generating a new Flow token. This action may be used to prevent users from initiating the same Flow again. @@ -513,6 +519,7 @@ def delete(self) -> bool: def deprecate(self) -> bool: """ When the flow is in ``FlowStatus.PUBLISHED`` status, you can only deprecate it. + - A shortcut for :meth:`pywa.client.WhatsApp.deprecate_flow`. Returns: Whether the flow was deprecated. @@ -528,6 +535,7 @@ def deprecate(self) -> bool: def get_assets(self) -> tuple[FlowAsset, ...]: """ Get all assets attached to this flow. + - A shortcut for :meth:`pywa.client.WhatsApp.get_flow_assets`. Returns: The assets of the flow. @@ -541,8 +549,10 @@ def update_metadata( endpoint_uri: str | None = None, ) -> bool: """ + Update the metadata of this flow. + - A shortcut for :meth:`pywa.client.WhatsApp.update_flow_metadata`. + Args: - flow_id: The flow ID. name: The name of the flow (optional). categories: The new categories of the flow (optional). endpoint_uri: The URL of the FlowJSON Endpoint. Starting from FlowJSON 3.0 this property should be @@ -585,6 +595,7 @@ def update_json( ) -> bool: """ Update the json of this flow. + - A shortcut for :meth:`pywa.client.WhatsApp.update_flow_json`. Args: flow_json: The new json of the flow. Can be a :class:`FlowJSON` object, :class:`dict`, json :class:`str`, @@ -637,6 +648,11 @@ def from_dict(cls, data: dict): "refresh_on_back", } +_SKIP_KEYS = { + "init_value", # Default value copied to Form.init_values + "error_message", # Error message copied to Form.error_messages +} + @dataclasses.dataclass(slots=True, kw_only=True) class FlowJSON: @@ -678,11 +694,101 @@ def to_dict(self): dict_factory=lambda d: { k.replace("_", "-") if k not in _UNDERSCORE_FIELDS else k: v for (k, v) in d - if v is not None + if k not in _SKIP_KEYS and v is not None }, ) +@dataclasses.dataclass(slots=True, kw_only=True) +class DataSource: + """ + The data source of a component. + + Example: + + >>> from pywa.types.flows import DataSource + >>> option_1 = DataSource(id='1', title='Option 1') + >>> option_2 = DataSource(id='2', title='Option 2') + >>> checkbox_group = CheckboxGroup(data_source=[option_1, option_2], ...) + + Attributes: + id: The ID of the data source. + title: The title of the data source. Limited to 30 characters. + description: The description of the data source. Limited to 300 characters. + metadata: The metadata of the data source. Limited to 20 characters. + enabled: Whether the data source is enabled or not. Default to ``True``. + """ + + id: str + title: str + description: str | None = None + metadata: str | None = None + enabled: bool | None = None + + +@dataclasses.dataclass(slots=True, kw_only=True) +class ScreenData: + """ + Represents a screen data that a screen should get from the previous screen or from the data endpoint. + + - You can use the :class:`DataKey` or the ``.data_key`` property to reference this data in the screen children. + - Read more at `developers.facebook.com `_. + + Example: + + >>> from pywa.types.flows import ScreenData + >>> dynamic_welcome = ScreenData(key='welcome', example='Welcome to my store!') + >>> is_email_required = ScreenData(key='is_email_required', example=False) + >>> screen = Screen( + ... id='START', + ... data=[dynamic_welcome, is_email_required], + ... layout=Layout(children=[Form(children=[ + ... TextHeading(text=dynamic_welcome.data_key, ...), + ... TextInput(required=is_email_required.data_key, input_type=InputType.EMAIL, ...) + ... ])]) + ... ) + + Attributes: + key: The key of the data (To use later in the screen children with :class:`DataKey`). + example: The example of the data that the screen should get from the previous screen or from the data endpoint. + """ + + key: str + example: str | int | float | bool | dict | DataSource | Iterable[ + str | int | float | bool | dict | DataSource + ] + + @property + def data_key(self) -> str: + """ + The key for this data to use in the screen children. + - A shortcut for :class:`DataKey` with this key. + + Example: + + >>> from pywa.types.flows import Screen, ScreenData, TextHeading, DataKey + >>> dynamic_welcome = ScreenData(key='welcome', example='Welcome to my store!') + + >>> screen = Screen( + ... id='START', + ... data=[dynamic_welcome], + ... layout=Layout(children=[ + ... TextHeading(text=dynamic_welcome.data_key, ...) + ... ]) + ... ) + + """ + return DataKey(self.key) + + +_PY_TO_JSON_TYPES = { + str: "string", + int: "number", + float: "number", + bool: "boolean", +} + + @dataclasses.dataclass(slots=True, kw_only=True) class Screen: """ @@ -691,26 +797,97 @@ class Screen: - The maximum number of components (children) per screen is 50. - Read more at `developers.facebook.com `_. + Example: + + >>> from pywa.types.flows import Screen + >>> screen = Screen( + ... id='START', + ... title='Welcome', + ... data=[ScreenData(key='welcome', example='Welcome to my store!')], + ... terminal=True, + ... layout=Layout(children=[Form(children=[...])]), + ... refresh_on_back=True + ... ) + Attributes: - id: Unique identifier of the screen which works as a page url. ``SUCCESS`` is a reserved keyword and should not be used as a screen id. - layout: Associated screen UI Layout that is shown to the user (Read more at `developers.facebook.com `_). + id: Unique identifier of the screen for navigation purposes. ``SUCCESS`` is a reserved keyword and should not be used as a screen id. title: Screen level attribute that is rendered in the top navigation bar. - data: Declaration of dynamic data that fills the components field in the Flow JSON. It uses JSON Schema to define the structure and type of the properties. - terminal: Each Flow should have a terminal state where we terminate the experience and have the Flow completed. Multiple screens can be marked as terminal. It's mandatory to have a Footer component on the terminal screen. + data: Declaration of dynamic data that this screen should get from the previous screen or from the data endpoint. In the screen children, you can use the :class:`DataKey` to get the data from this attribute. + terminal: Each Flow should have a terminal state where we terminate the experience and have the Flow completed. Multiple screens can be marked as terminal. It's mandatory to have a :class:`Footer` on the terminal screen. refresh_on_back: Whether to trigger a data exchange request with the WhatsApp Flows Data Endpoint when using the back button while on this screen (Read more at `developers.facebook.com `_). + layout: Associated screen UI Layout that is shown to the user (Read more at `developers.facebook.com `_). """ id: str - layout: Layout title: str | None = None - data: dict[str, dict] | None = None + data: Iterable[ScreenData] | dict[str, dict] | None = None terminal: bool | None = None refresh_on_back: bool | None = None + layout: Layout + + def __post_init__(self): + if not self.data or isinstance(self.data, dict): + return + + data = {} + for item in self.data: + try: + data[item.key] = dict( + **_get_json_type(item.example), __example__=item.example + ) + except KeyError as e: + raise ValueError( + f"Invalid example type {type(item.example)!r} for {item.key!r}. " + f"{e}" + ) + + self.data = data or None + + +def _get_json_type( + example: str + | int + | float + | bool + | DataSource + | Iterable[str | int | float | bool | DataSource], +) -> dict[str, str | dict[str, str]]: + if isinstance(example, (str, int, float, bool)): + return {"type": _PY_TO_JSON_TYPES[type(example)]} + elif isinstance(example, (dict, DataSource)): + return {"type": "object", "properties": _get_obj_props(example)} + elif isinstance(example, Iterable): + try: + first = next(iter(example)) + except StopIteration: + raise ValueError("At least one example is required when using Iterable") + if isinstance(first, (str, int, float, bool)): + return {"type": "array", "items": {"type": _PY_TO_JSON_TYPES[type(first)]}} + elif isinstance(first, (dict, DataSource)): + return { + "type": "array", + "items": {"type": "object", "properties": _get_obj_props(first)}, + } + else: + raise KeyError("Invalid example type") + + +def _get_obj_props(item: dict | DataSource): + return { + k: dict(type=_PY_TO_JSON_TYPES[type(v)]) + for k, v in ( + dataclasses.asdict(item).items() + if isinstance(item, DataSource) + else item.items() + ) + if v is not None + } class LayoutType(utils.StrEnum): """ The type of layout that is used to display the components. + - Currently, only ``LayoutType.SINGLE_COLUMN`` is supported. Attributes: SINGLE_COLUMN: A single column layout. @@ -791,6 +968,8 @@ def __new__(cls, key: str): """ Represents a data key (converts to ``${data.}``). + - Hint: use the ``.data_key`` property of :class:`ScreenData` to get the data key of a screen data. + Args: key: The key to get from the :class:`Screen` .data attribute. """ @@ -802,6 +981,8 @@ def __new__(cls, child_name: str, form_name: str = "form"): """ Represents a form reference variable (converts to ``${form.}``). + - Hint: use the ``.form_ref`` property of each component to get the form reference variable of that component. + Args: child_name: The name of the :class:`Form` child to get the value from. form_name: The name of the :class:`Form` to get the child from. Default to ``"form"``. @@ -850,6 +1031,96 @@ class Form(Component): init_values: dict[str, Any] | str | DataKey | None = None error_messages: dict[str, str] | str | DataKey | None = None + def __post_init__(self): + if not self.children: + raise ValueError("At least one child is required") + if not isinstance(self.init_values, str): + init_values = self.init_values or {} + for child in self.children: + if getattr(child, "init_value", None) is not None: + if child.name in init_values: + raise ValueError( + f"Duplicate init value for {child.name!r} in form {self.name!r}" + ) + if isinstance(self.init_values, str): + raise ValueError( + f"No need to set init value for {child.name!r} if form init values is a dynamic DataKey" + ) + init_values[child.name] = child.init_value + self.init_values = init_values or None + + if not isinstance(self.error_messages, str): + error_messages = self.error_messages or {} + for child in self.children: + if getattr(child, "error_message", None) is not None: + if child.name in error_messages: + raise ValueError( + f"Duplicate error msg for {child.name!r} in form {self.name!r}" + ) + if isinstance(self.error_messages, str): + raise ValueError( + f"No need to set error msg for {child.name!r} if form error messages is a dynamic DataKey" + ) + error_messages[child.name] = child.error_message + self.error_messages = error_messages or None + + +class FormComponent(Component, abc.ABC): + """Base class for all components that must be inside a form""" + + @property + @abc.abstractmethod + def name(self) -> str: + ... + + @property + @abc.abstractmethod + def label(self) -> str | DataKey: + ... + + @property + @abc.abstractmethod + def required(self) -> bool | str | DataKey | None: + ... + + @property + @abc.abstractmethod + def enabled(self) -> bool | str | DataKey | None: + ... + + @property + @abc.abstractmethod + def init_value(self) -> bool | str | DataKey | None: + ... + + @property + def form_ref(self) -> str: + """ + The form reference variable for this component. + - A shortcut for :class:`FormRef` with this component name. + - Use this when form name is ``"form"``, otherwise use ``.form_ref_of`` method. + + Example: + + >>> from pywa.types.flows import Form, TextInput + >>> form = Form(children=[text_input := TextInput(name='email', ...)]) + >>> text_input.form_ref + """ + return FormRef(self.name) + + def form_ref_of(self, form_name: str) -> str: + """ + The form reference variable for this component with the given form name. + - A shortcut for :class:`FormRef` with the given form name. + + Example: + + >>> from pywa.types.flows import Form, TextInput + >>> form = Form(name='my_form', children=[text_input := TextInput(name='email', ...)]) + >>> text_input.form_ref_of('my_form') + """ + return FormRef(child_name=self.name, form_name=form_name) + class TextComponent(Component, abc.ABC): """ @@ -889,8 +1160,8 @@ class TextHeading(TextComponent): - Read more at `developers.facebook.com `_. Attributes: - text: The text of the heading. Limited to 4096 characters. Can be dynamic (e.g ``DataKey("text")``). - visible: Whether the heading is visible or not. Default to ``True``, Can be dynamic (e.g ``DataKey("is_visible")``). + text: The text of the heading. Limited to 4096 characters. Can be dynamic. + visible: Whether the heading is visible or not. Default to ``True``, Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -908,8 +1179,8 @@ class TextSubheading(TextComponent): - Read more at `developers.facebook.com `_. Attributes: - text: The text of the subheading. Limited to 60 characters. Can be dynamic (e.g ``DataKey("text")``). - visible: Whether the subheading is visible or not. Default to ``True``, Can be dynamic (e.g ``DataKey("is_visible")``). + text: The text of the subheading. Limited to 60 characters. Can be dynamic. + visible: Whether the subheading is visible or not. Default to ``True``, Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -927,10 +1198,10 @@ class TextBody(TextComponent): - Read more at `developers.facebook.com `_. Attributes: - text: The text of the body. Limited to 80 characters. Can be dynamic (e.g ``DataKey("text")``). - font_weight: The weight of the text. Can be dynamic (e.g ``DataKey("font_weight")``). - strikethrough: Whether the text is strikethrough or not. Can be dynamic (e.g ``DataKey("strikethrough")``). - visible: Whether the body is visible or not. Default to ``True``, Can be dynamic (e.g ``DataKey("is_visible")``). + text: The text of the body. Limited to 80 characters. Can be dynamic. + font_weight: The weight of the text. Can be dynamic. + strikethrough: Whether the text is strikethrough or not. Can be dynamic. + visible: Whether the body is visible or not. Default to ``True``, Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -950,10 +1221,10 @@ class TextCaption(TextComponent): - Read more at `developers.facebook.com `_. Attributes: - text: The text of the caption. Limited to 4096 characters. Can be dynamic (e.g ``DataKey("text")``). - font_weight: The weight of the text. Can be dynamic (e.g ``DataKey("font_weight")``). - strikethrough: Whether the text is strikethrough or not. Can be dynamic (e.g ``DataKey("strikethrough")``). - visible: Whether the caption is visible or not. Default to ``True``, Can be dynamic (e.g ``DataKey("is_visible")``). + text: The text of the caption. Limited to 4096 characters. Can be dynamic. + font_weight: The weight of the text. Can be dynamic. + strikethrough: Whether the text is strikethrough or not. Can be dynamic. + visible: Whether the caption is visible or not. Default to ``True``, Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -965,7 +1236,7 @@ class TextCaption(TextComponent): visible: bool | str | DataKey | None = None -class TextEntryComponent(Component, abc.ABC): +class TextEntryComponent(FormComponent, abc.ABC): """ Base class for all text entry components @@ -974,22 +1245,12 @@ class TextEntryComponent(Component, abc.ABC): @property @abc.abstractmethod - def name(self) -> str: - ... - - @property - @abc.abstractmethod - def label(self) -> str | DataKey: - ... - - @property - @abc.abstractmethod - def required(self) -> bool | str | DataKey | None: + def helper_text(self) -> str | DataKey | None: ... @property @abc.abstractmethod - def helper_text(self) -> str | DataKey | None: + def error_message(self) -> str | DataKey | None: ... @@ -1026,14 +1287,16 @@ class TextInput(TextEntryComponent): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - label: The label of the text input. Limited to 20 characters. Can be dynamic (e.g ``DataKey("label")``). - input_type: The input type of the text input (for keyboard layout and validation rules). Can be dynamic (e.g ``DataKey("input_type")``). - required: Whether the text input is required or not. Can be dynamic (e.g ``DataKey("required")``). - min_chars: The minimum number of characters allowed in the text input. Can be dynamic (e.g ``DataKey("min_chars")``). - max_chars: The maximum number of characters allowed in the text input. Can be dynamic (e.g ``DataKey("max_chars")``). - helper_text: The helper text of the text input. Limited to 80 characters. Can be dynamic (e.g ``DataKey("helper_text")``). - enabled: Whether the text input is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). - visible: Whether the text input is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + label: The label of the text input. Limited to 20 characters. Can be dynamic. + input_type: The input type of the text input (for keyboard layout and validation rules). Can be dynamic. + required: Whether the text input is required or not. Can be dynamic. + min_chars: The minimum number of characters allowed in the text input. Can be dynamic. + max_chars: The maximum number of characters allowed in the text input. Can be dynamic. + helper_text: The helper text of the text input. Limited to 80 characters. Can be dynamic. + enabled: Whether the text input is enabled or not. Default to ``True``. Can be dynamic. + visible: Whether the text input is visible or not. Default to ``True``. Can be dynamic. + init_value: The default value of the text input. Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. + error_message: The error message of the text input. Shortcuts for ``error_messages`` of the parent :class:`Form`. Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -1048,6 +1311,8 @@ class TextInput(TextEntryComponent): helper_text: str | DataKey | None = None enabled: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None + init_value: str | DataKey | None = None + error_message: str | DataKey | None = None @dataclasses.dataclass(slots=True, kw_only=True) @@ -1060,12 +1325,14 @@ class TextArea(TextEntryComponent): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - label: The label of the text area. Limited to 20 characters. Can be dynamic (e.g ``DataKey("label")``). - required: Whether the text area is required or not. Can be dynamic (e.g ``DataKey("required")``). - max_length: The maximum number of characters allowed in the text area. Can be dynamic (e.g ``DataKey("max_length")``). - helper_text: The helper text of the text area. Limited to 80 characters. Can be dynamic (e.g ``DataKey("helper_text")``). - enabled: Whether the text area is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). - visible: Whether the text area is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + label: The label of the text area. Limited to 20 characters. Can be dynamic. + required: Whether the text area is required or not. Can be dynamic. + max_length: The maximum number of characters allowed in the text area. Can be dynamic. + helper_text: The helper text of the text area. Limited to 80 characters. Can be dynamic. + enabled: Whether the text area is enabled or not. Default to ``True``. Can be dynamic. + visible: Whether the text area is visible or not. Default to ``True``. Can be dynamic. + init_value: The default value of the text area. Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. + error_message: The error message of the text area. Shortcuts for ``error_messages`` of the parent :class:`Form`. Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -1078,30 +1345,12 @@ class TextArea(TextEntryComponent): helper_text: str | DataKey | None = None enabled: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None + init_value: str | DataKey | None = None + error_message: str | DataKey | None = None @dataclasses.dataclass(slots=True, kw_only=True) -class DataSource: - """ - The data source of a component. - - Attributes: - id: The ID of the data source. - title: The title of the data source. Limited to 30 characters. - description: The description of the data source. Limited to 300 characters. - metadata: The metadata of the data source. Limited to 20 characters. - enabled: Whether the data source is enabled or not. Default to ``True``. - """ - - id: str - title: str - description: str | None = None - metadata: str | None = None - enabled: bool | None = None - - -@dataclasses.dataclass(slots=True, kw_only=True) -class CheckboxGroup(Component): +class CheckboxGroup(FormComponent): """ CheckboxGroup component allows users to pick multiple selections from a list of options. @@ -1110,13 +1359,14 @@ class CheckboxGroup(Component): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - data_source: The data source of the checkbox group. Can be dynamic (e.g ``DataKey("data_source")``). - label: The label of the checkbox group. Limited to 30 characters. Can be dynamic (e.g ``DataKey("label")``). - min_selected_items: The minimum number of items that can be selected. Minimum value is 1. Can be dynamic (e.g ``DataKey("min_selected_items")``). - max_selected_items: The maximum number of items that can be selected. Maximum value is 20. Can be dynamic (e.g ``DataKey("max_selected_items")``). - required: Whether the checkbox group is required or not. Can be dynamic (e.g ``DataKey("required")``). - visible: Whether the checkbox group is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). - enabled: Whether the checkbox group is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). + data_source: The data source of the checkbox group. Can be dynamic. + label: The label of the checkbox group. Limited to 30 characters. Can be dynamic. + min_selected_items: The minimum number of items that can be selected. Minimum value is 1. Can be dynamic. + max_selected_items: The maximum number of items that can be selected. Maximum value is 20. Can be dynamic. + required: Whether the checkbox group is required or not. Can be dynamic. + visible: Whether the checkbox group is visible or not. Default to ``True``. Can be dynamic. + enabled: Whether the checkbox group is enabled or not. Default to ``True``. Can be dynamic. + init_value: The default values (IDs of the data sources). Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. on_select_action: The action to perform when an item is selected. """ @@ -1131,11 +1381,12 @@ class CheckboxGroup(Component): required: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None enabled: bool | str | DataKey | None = None + init_value: list[str] | str | DataKey | None = None on_select_action: Action | None = None @dataclasses.dataclass(slots=True, kw_only=True) -class RadioButtonsGroup(Component): +class RadioButtonsGroup(FormComponent): """ RadioButtonsGroup component allows users to pick a single selection from a list of options. @@ -1144,11 +1395,12 @@ class RadioButtonsGroup(Component): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - data_source: The data source of the radio buttons group. Can be dynamic (e.g ``DataKey("data_source")``). - label: The label of the radio buttons group. Limited to 30 characters. Can be dynamic (e.g ``DataKey("label")``). - required: Whether the radio buttons group is required or not. Can be dynamic (e.g ``DataKey("required")``). - visible: Whether the radio buttons group is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). - enabled: Whether the radio buttons group is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). + data_source: The data source of the radio buttons group. Can be dynamic. + label: The label of the radio buttons group. Limited to 30 characters. Can be dynamic. + required: Whether the radio buttons group is required or not. Can be dynamic. + visible: Whether the radio buttons group is visible or not. Default to ``True``. Can be dynamic. + enabled: Whether the radio buttons group is enabled or not. Default to ``True``. Can be dynamic. + init_value: The default value (ID of the data source). Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. on_select_action: The action to perform when an item is selected. """ @@ -1161,6 +1413,7 @@ class RadioButtonsGroup(Component): required: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None enabled: bool | str | DataKey | None = None + init_value: str | DataKey | None = None on_select_action: Action | None = None @@ -1172,12 +1425,12 @@ class Footer(Component): - Read more at `developers.facebook.com `_. Attributes: - label: The label of the footer. Limited to 35 characters. Can be dynamic (e.g ``DataKey("label")``). + label: The label of the footer. Limited to 35 characters. Can be dynamic. on_click_action: The action to perform when the footer is clicked. Required. - left_caption: Can set left_caption and right_caption or only center_caption, but not all 3 at once. Limited to 15 characters. Can be dynamic (e.g ``DataKey("left_caption")``). - center_caption: Can set center-caption or left-caption and right-caption, but not all 3 at once. Limited to 15 characters. Can be dynamic (e.g ``DataKey("center_caption")``). - right_caption: Can set right-caption and left-caption or only center-caption, but not all 3 at once. Limited to 15 characters. Can be dynamic (e.g ``DataKey("right_caption")``). - enabled: Whether the footer is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). + left_caption: Can set left_caption and right_caption or only center_caption, but not all 3 at once. Limited to 15 characters. Can be dynamic. + center_caption: Can set center-caption or left-caption and right-caption, but not all 3 at once. Limited to 15 characters. Can be dynamic. + right_caption: Can set right-caption and left-caption or only center-caption, but not all 3 at once. Limited to 15 characters. Can be dynamic. + enabled: Whether the footer is enabled or not. Default to ``True``. Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -1193,7 +1446,7 @@ class Footer(Component): @dataclasses.dataclass(slots=True, kw_only=True) -class OptIn(Component): +class OptIn(FormComponent): """ OptIn component allows users to check a box to opt in for a specific purpose. @@ -1203,24 +1456,27 @@ class OptIn(Component): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - label: The label of the opt in. Limited to 30 characters. Can be dynamic (e.g ``DataKey("label")``). - required: Whether the opt in is required or not. Can be dynamic (e.g ``DataKey("required")``). - visible: Whether the opt in is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + label: The label of the opt in. Limited to 30 characters. Can be dynamic. + required: Whether the opt in is required or not. Can be dynamic. + visible: Whether the opt in is visible or not. Default to ``True``. Can be dynamic. + init_value: The default value of the opt in. Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. on_click_action: The action to perform when the opt in is clicked. """ type: ComponentType = dataclasses.field( default=ComponentType.OPT_IN, init=False, repr=False ) + enabled: None = dataclasses.field(default=None, init=False, repr=False) name: str label: str | DataKey required: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None + init_value: bool | str | DataKey | None = None on_click_action: Action | None = None @dataclasses.dataclass(slots=True, kw_only=True) -class Dropdown(Component): +class Dropdown(FormComponent): """ Dropdown component allows users to pick a single selection from a list of options. @@ -1229,11 +1485,12 @@ class Dropdown(Component): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - label: The label of the dropdown. Limited to 30 characters. Can be dynamic (e.g ``DataKey("label")``). - data_source: The data source of the dropdown. minimum 1 and maximum 200 items. Can be dynamic (e.g ``DataKey("data_source")``). - enabled: Whether the dropdown is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). - required: Whether the dropdown is required or not. Can be dynamic (e.g ``DataKey("required")``). - visible: Whether the dropdown is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + label: The label of the dropdown. Limited to 30 characters. Can be dynamic. + data_source: The data source of the dropdown. minimum 1 and maximum 200 items. Can be dynamic. + enabled: Whether the dropdown is enabled or not. Default to ``True``. Can be dynamic. + required: Whether the dropdown is required or not. Can be dynamic. + visible: Whether the dropdown is visible or not. Default to ``True``. Can be dynamic. + init_value: The default value (ID of the data source). Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. on_select_action: The action to perform when an item is selected. """ @@ -1246,6 +1503,7 @@ class Dropdown(Component): enabled: bool | str | DataKey | None = None required: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None + init_value: str | DataKey | None = None on_select_action: Action | None = None @@ -1260,9 +1518,9 @@ class EmbeddedLink(Component): - Read more at `developers.facebook.com `_. Attributes: - text: The text of the embedded link. Limited to 35 characters. Can be dynamic (e.g ``DataKey("text")``). + text: The text of the embedded link. Limited to 35 characters. Can be dynamic. on_click_action: The action to perform when the embedded link is clicked. - visible: Whether the embedded link is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + visible: Whether the embedded link is visible or not. Default to ``True``. Can be dynamic. """ type: ComponentType = dataclasses.field( @@ -1274,7 +1532,7 @@ class EmbeddedLink(Component): @dataclasses.dataclass(slots=True, kw_only=True) -class DatePicker(Component): +class DatePicker(FormComponent): """ DatePicker component allows users to select a date @@ -1283,14 +1541,16 @@ class DatePicker(Component): Attributes: name: The unique name (id) for this component (to be used dynamically or in action payloads). - label: The label of the date picker. Limited to 40 characters. Can be dynamic (e.g ``DataKey("label")``). - min_date: The minimum date (timestamp in ms) that can be selected. Can be dynamic (e.g ``DataKey("min_date")``). - max_date: The maximum date (timestamp in ms) that can be selected. Can be dynamic (e.g ``DataKey("max_date")``). - unavailable_dates: The dates (timestamp in ms) that cannot be selected. Can be dynamic (e.g ``DataKey("unavailable_dates")``). - helper_text: The helper text of the date picker. Limited to 80 characters. Can be dynamic (e.g ``DataKey("helper_text")``). - enabled: Whether the date picker is enabled or not. Default to ``True``. Can be dynamic (e.g ``DataKey("enabled")``). - required: Whether the date picker is required or not. Can be dynamic (e.g ``DataKey("required")``). - visible: Whether the date picker is visible or not. Default to ``True``. Can be dynamic (e.g ``DataKey("is_visible")``). + label: The label of the date picker. Limited to 40 characters. Can be dynamic. + min_date: The minimum date (timestamp in ms) that can be selected. Can be dynamic. + max_date: The maximum date (timestamp in ms) that can be selected. Can be dynamic. + unavailable_dates: The dates (timestamp in ms) that cannot be selected. Can be dynamic. + helper_text: The helper text of the date picker. Limited to 80 characters. Can be dynamic. + enabled: Whether the date picker is enabled or not. Default to ``True``. Can be dynamic. + required: Whether the date picker is required or not. Can be dynamic. + visible: Whether the date picker is visible or not. Default to ``True``. Can be dynamic. + init_value: The default value. Shortcut for ``init_values`` of the parent :class:`Form`. Can be dynamic. + error_message: The error message of the date picker. Shortcuts for ``error_messages`` of the parent :class:`Form`. Can be dynamic. on_select_action: The action to perform when a date is selected. """ @@ -1306,6 +1566,8 @@ class DatePicker(Component): enabled: bool | str | DataKey | None = None required: bool | str | DataKey | None = None visible: bool | str | DataKey | None = None + init_value: str | DataKey | None = None + error_message: str | DataKey | None = None on_select_action: Action | None = None @@ -1336,11 +1598,11 @@ class Image(Component): Attributes: src: Base64 of an image. - width: The width of the image. Can be dynamic (e.g ``DataKey("width")``). - height: The height of the image. Can be dynamic (e.g ``DataKey("height")``). - scale_type: The scale type of the image. Defaule to ``ScaleType.CONTAIN`` Can be dynamic (e.g ``DataKey("scale_type")``). Read more at `developers.facebook.com `_. - aspect_ratio: The aspect ratio of the image. Default to ``1``. Can be dynamic (e.g ``DataKey("aspect_ratio")``). - alt_text: Alternative Text is for the accessibility feature, eg. Talkback and Voice over. Can be dynamic (e.g ``DataKey("alt_text")``). + width: The width of the image. Can be dynamic. + height: The height of the image. Can be dynamic. + scale_type: The scale type of the image. Defaule to ``ScaleType.CONTAIN`` Can be dynamic. Read more at `developers.facebook.com `_. + aspect_ratio: The aspect ratio of the image. Default to ``1``. Can be dynamic. + alt_text: Alternative Text is for the accessibility feature, eg. Talkback and Voice over. Can be dynamic. """ type: ComponentType = dataclasses.field( diff --git a/tests/data/flows/2.1/examples.json b/tests/data/flows/2.1/examples.json index eea7f01..4ccf19a 100644 --- a/tests/data/flows/2.1/examples.json +++ b/tests/data/flows/2.1/examples.json @@ -326,7 +326,7 @@ "items": { "type": "string" }, - "__example__": [] + "__example__": ["Example", "Example2"] } }, "layout": { @@ -393,7 +393,7 @@ "items": { "type": "string" }, - "__example__": [] + "__example__": ["Example", "Example2"] } }, "terminal": true, @@ -1016,6 +1016,11 @@ "data": { "error_messages": { "type": "object", + "properties": { + "confirm_password": { + "type": "string" + } + }, "__example__": { "confirm_password": "Passwords don't match." } diff --git a/tests/test_flows.py b/tests/test_flows.py index 98174ac..855796f 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -27,6 +27,7 @@ EmbeddedLink, DataKey, FormRef, + ScreenData, ) FLOWS_VERSION = "2.1" @@ -45,7 +46,7 @@ name="form", children=[ TextSubheading(text="Would you recommend us to a friend?"), - RadioButtonsGroup( + recommend_radio := RadioButtonsGroup( name="recommend_radio", label="Choose one", data_source=[ @@ -55,7 +56,7 @@ required=True, ), TextSubheading(text="How could we do better?"), - TextArea( + comment_text := TextArea( name="comment_text", label="Leave a comment", required=False, @@ -68,8 +69,8 @@ type=ActionNextType.SCREEN, name="RATE" ), payload={ - "recommend_radio": FormRef("recommend_radio"), - "comment_text": FormRef("comment_text"), + "recommend_radio": recommend_radio.form_ref, + "comment_text": comment_text.form_ref, }, ), ), @@ -81,10 +82,10 @@ Screen( id="RATE", title="Feedback 2 of 2", - data={ - "recommend_radio": {"type": "string", "__example__": "Example"}, - "comment_text": {"type": "string", "__example__": "Example"}, - }, + data=[ + recommend_radio := ScreenData(key="recommend_radio", example="Example"), + comment_text := ScreenData(key="comment_text", example="Example"), + ], terminal=True, layout=Layout( type=LayoutType.SINGLE_COLUMN, @@ -93,7 +94,7 @@ name="form", children=[ TextSubheading(text="Rate the following: "), - Dropdown( + purchase_rating := Dropdown( name="purchase_rating", label="Purchase experience", required=True, @@ -105,7 +106,7 @@ DataSource(id="4", title="★☆☆☆☆ • Very Poor (1/5)"), ], ), - Dropdown( + delivery_rating := Dropdown( name="delivery_rating", label="Delivery and setup", required=True, @@ -117,7 +118,7 @@ DataSource(id="4", title="★☆☆☆☆ • Very Poor (1/5)"), ], ), - Dropdown( + cs_rating := Dropdown( name="cs_rating", label="Customer service", required=True, @@ -134,11 +135,11 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "purchase_rating": FormRef("purchase_rating"), - "delivery_rating": FormRef("delivery_rating"), - "cs_rating": FormRef("cs_rating"), - "recommend_radio": DataKey("recommend_radio"), - "comment_text": DataKey("comment_text"), + "purchase_rating": purchase_rating.form_ref, + "delivery_rating": delivery_rating.form_ref, + "cs_rating": cs_rating.form_ref, + "recommend_radio": recommend_radio.data_key, + "comment_text": comment_text.data_key, }, ), ), @@ -164,21 +165,21 @@ Form( name="form", children=[ - TextInput( + first_name := TextInput( name="firstName", label="First Name", input_type=InputType.TEXT, required=True, visible=True, ), - TextInput( + last_name := TextInput( name="lastName", label="Last Name", input_type=InputType.TEXT, required=True, visible=True, ), - TextInput( + email := TextInput( name="email", label="Email Address", input_type=InputType.EMAIL, @@ -191,9 +192,9 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "firstName": FormRef("firstName"), - "lastName": FormRef("lastName"), - "email": FormRef("email"), + "firstName": first_name.form_ref, + "lastName": last_name.form_ref, + "email": email.form_ref, }, ), ), @@ -221,7 +222,7 @@ TextHeading( text="You've found the perfect deal, what do you do next?" ), - CheckboxGroup( + question1_checkbox := CheckboxGroup( name="question1Checkbox", label="Choose all that apply:", required=True, @@ -247,7 +248,7 @@ type=ActionNextType.SCREEN, name="QUESTION_TWO" ), payload={ - "question1Checkbox": "${form.question1Checkbox}" + "question1Checkbox": question1_checkbox.form_ref }, ), ), @@ -259,13 +260,11 @@ Screen( id="QUESTION_TWO", title="Question 2 of 3", - data={ - "question1Checkbox": { - "type": "array", - "items": {"type": "string"}, - "__example__": [], - } - }, + data=[ + question1_checkbox := ScreenData( + key="question1Checkbox", example=["Example", "Example2"] + ), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ @@ -275,7 +274,7 @@ TextHeading( text="Its your birthday in two weeks, how might you prepare?" ), - RadioButtonsGroup( + question2_radio_buttons := RadioButtonsGroup( name="question2RadioButtons", label="Choose all that apply:", required=True, @@ -294,8 +293,8 @@ name="QUESTION_THREE", ), payload={ - "question2RadioButtons": "${form.question2RadioButtons}", - "question1Checkbox": "${data.question1Checkbox}", + "question2RadioButtons": question2_radio_buttons.form_ref, + "question1Checkbox": question1_checkbox.data_key, }, ), ), @@ -307,14 +306,14 @@ Screen( id="QUESTION_THREE", title="Question 3 of 3", - data={ - "question2RadioButtons": {"type": "string", "__example__": "Example"}, - "question1Checkbox": { - "type": "array", - "items": {"type": "string"}, - "__example__": [], - }, - }, + data=[ + question2_radio_buttons := ScreenData( + key="question2RadioButtons", example="Example" + ), + question1_checkbox := ScreenData( + key="question1Checkbox", example=["Example", "Example2"] + ), + ], terminal=True, layout=Layout( type=LayoutType.SINGLE_COLUMN, @@ -323,7 +322,7 @@ name="form", children=[ TextHeading(text="What's the best gift for a friend?"), - CheckboxGroup( + question3_checkbox := CheckboxGroup( name="question3Checkbox", label="Choose all that apply:", required=True, @@ -339,9 +338,9 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "question1Checkbox": "${data.question1Checkbox}", - "question2RadioButtons": "${data.question2RadioButtons}", - "question3Checkbox": "${form.question3Checkbox}", + "question1Checkbox": question1_checkbox.data_key, + "question2RadioButtons": question2_radio_buttons.data_key, + "question3Checkbox": question3_checkbox.form_ref, }, ), ), @@ -367,20 +366,20 @@ Form( name="form", children=[ - TextInput( + name := TextInput( name="name", label="Name", input_type=InputType.TEXT, required=True, ), - TextInput( + order_number := TextInput( label="Order number", name="orderNumber", input_type=InputType.NUMBER, required=True, helper_text="", ), - RadioButtonsGroup( + topic_radio := RadioButtonsGroup( label="Choose a topic", name="topicRadio", data_source=[ @@ -392,7 +391,7 @@ ], required=True, ), - TextArea( + desc := TextArea( label="Description of issue", required=False, name="description", @@ -402,10 +401,10 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "name": FormRef("name"), - "orderNumber": FormRef("orderNumber"), - "topicRadio": FormRef("topicRadio"), - "description": FormRef("description"), + "name": name.form_ref, + "orderNumber": order_number.form_ref, + "topicRadio": topic_radio.form_ref, + "description": desc.form_ref, }, ), ), @@ -431,7 +430,7 @@ Form( name="form", children=[ - CheckboxGroup( + communication_types := CheckboxGroup( label="Communication types", required=True, name="communicationTypes", @@ -446,7 +445,7 @@ DataSource(id="3", title="New products"), ], ), - CheckboxGroup( + contact_prefs := CheckboxGroup( label="Contact Preferences", required=False, name="contactPrefs", @@ -461,10 +460,8 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "communicationTypes": FormRef( - "communicationTypes" - ), - "contactPrefs": FormRef("contactPrefs"), + "communicationTypes": communication_types.form_ref, + "contactPrefs": contact_prefs.form_ref, }, ), ), @@ -491,19 +488,19 @@ children=[ TextHeading(text="Join our next webinar!"), TextBody(text="First, we'll need a few details from you."), - TextInput( + first_name := TextInput( name="firstName", label="First Name", input_type=InputType.TEXT, required=True, ), - TextInput( + last_name := TextInput( label="Last Name", name="lastName", input_type=InputType.TEXT, required=True, ), - TextInput( + email := TextInput( label="Email Address", name="email", input_type=InputType.EMAIL, @@ -517,9 +514,9 @@ type=ActionNextType.SCREEN, name="SURVEY" ), payload={ - "firstName": FormRef("firstName"), - "lastName": FormRef("lastName"), - "email": FormRef("email"), + "firstName": first_name.form_ref, + "lastName": last_name.form_ref, + "email": email.form_ref, }, ), ), @@ -531,11 +528,11 @@ Screen( id="SURVEY", title="Thank you", - data={ - "firstName": {"type": "string", "__example__": "Example"}, - "lastName": {"type": "string", "__example__": "Example"}, - "email": {"type": "string", "__example__": "Example"}, - }, + data=[ + first_name := ScreenData(key="firstName", example="Example"), + last_name := ScreenData(key="lastName", example="Example"), + email := ScreenData(key="email", example="Example"), + ], terminal=True, layout=Layout( type=LayoutType.SINGLE_COLUMN, @@ -545,7 +542,7 @@ children=[ TextHeading(text="Before you go"), TextBody(text="How did you hear about us?"), - RadioButtonsGroup( + source := RadioButtonsGroup( name="source", label="Choose one", required=False, @@ -561,10 +558,10 @@ on_click_action=Action( name=FlowActionType.COMPLETE, payload={ - "source": FormRef("source"), - "firstName": DataKey("firstName"), - "lastName": DataKey("lastName"), - "email": DataKey("email"), + "source": source.form_ref, + "firstName": first_name.data_key, + "lastName": last_name.data_key, + "email": email.data_key, }, ), ), @@ -598,13 +595,13 @@ Form( name="sign_in_form", children=[ - TextInput( + email := TextInput( name="email", label="Email address", input_type=InputType.EMAIL, required=True, ), - TextInput( + password := TextInput( name="password", label="Password", input_type=InputType.PASSWORD, @@ -636,8 +633,8 @@ on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={ - "email": FormRef("email"), - "password": FormRef("password"), + "email": email.form_ref, + "password": password.form_ref, }, ), ), @@ -656,37 +653,37 @@ Form( name="sign_up_form", children=[ - TextInput( + first_name := TextInput( name="first_name", label="First Name", input_type=InputType.TEXT, required=True, ), - TextInput( + last_name := TextInput( name="last_name", label="Last Name", input_type=InputType.TEXT, required=True, ), - TextInput( + email := TextInput( name="email", label="Email address", input_type=InputType.EMAIL, required=True, ), - TextInput( + password := TextInput( name="password", label="Set password", input_type=InputType.PASSWORD, required=True, ), - TextInput( + confirm_password := TextInput( name="confirm_password", label="Confirm password", input_type=InputType.PASSWORD, required=True, ), - OptIn( + terms_agreement := OptIn( name="terms_agreement", label="I agree with the terms.", required=True, @@ -699,7 +696,7 @@ payload={}, ), ), - OptIn( + offers_acceptance := OptIn( name="offers_acceptance", label="I would like to receive news and offers.", ), @@ -708,15 +705,13 @@ on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={ - "first_name": FormRef("first_name"), - "last_name": FormRef("last_name"), - "email": FormRef("email"), - "password": FormRef("password"), - "confirm_password": FormRef("confirm_password"), - "terms_agreement": FormRef("terms_agreement"), - "offers_acceptance": FormRef( - "offers_acceptance" - ), + "first_name": first_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, + "password": password.form_ref, + "confirm_password": confirm_password.form_ref, + "terms_agreement": terms_agreement.form_ref, + "offers_acceptance": offers_acceptance.form_ref, }, ), ), @@ -729,20 +724,23 @@ id="FORGOT_PASSWORD", title="Forgot password", terminal=True, - data={ - "body": { - "type": "string", - "__example__": "Enter your email address for your account and we'll send a reset link. The single-use link will expire after 24 hours.", - } - }, + data=[ + body := ScreenData( + key="body", + example=( + "Enter your email address for your account and we'll send a reset link. " + "The single-use link will expire after 24 hours." + ), + ), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="forgot_password_form", children=[ - TextBody(text=DataKey("body")), - TextInput( + TextBody(text=body.data_key), + email := TextInput( name="email", label="Email address", input_type=InputType.EMAIL, @@ -752,7 +750,7 @@ label="Sign in", on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, - payload={"email": FormRef("email")}, + payload={"email": email.form_ref}, ), ), ], @@ -792,50 +790,50 @@ id="REGISTER", title="Register for an account", terminal=True, - data={ - "error_messages": { - "type": "object", - "__example__": {"confirm_password": "Passwords don't match."}, - } - }, + data=[ + error_messages := ScreenData( + key="error_messages", + example={"confirm_password": "Passwords don't match."}, + ), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="register_form", - error_messages=DataKey("error_messages"), + error_messages=error_messages.data_key, children=[ - TextInput( + first_name := TextInput( name="first_name", required=True, label="First name", input_type="text", ), - TextInput( + last_name := TextInput( name="last_name", required=True, label="Last name", input_type="text", ), - TextInput( + email := TextInput( name="email", required=True, label="Email address", input_type="email", ), - TextInput( + password := TextInput( name="password", required=True, label="Set password", input_type="password", ), - TextInput( + confirm_password := TextInput( name="confirm_password", required=True, label="Confirm password", input_type="password", ), - OptIn( + terms_agreement := OptIn( name="terms_agreement", label="I agree with the terms.", required=True, @@ -848,7 +846,7 @@ payload={}, ), ), - OptIn( + offers_acceptance := OptIn( name="offers_acceptance", label="I would like to receive news and offers.", ), @@ -857,15 +855,13 @@ on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={ - "first_name": FormRef("first_name"), - "last_name": FormRef("last_name"), - "email": FormRef("email"), - "password": FormRef("password"), - "confirm_password": FormRef("confirm_password"), - "terms_agreement": FormRef("terms_agreement"), - "offers_acceptance": FormRef( - "offers_acceptance" - ), + "first_name": first_name.form_ref, + "last_name": last_name.form_ref, + "email": email.form_ref, + "password": password.form_ref, + "confirm_password": confirm_password.form_ref, + "terms_agreement": terms_agreement.form_ref, + "offers_acceptance": offers_acceptance.form_ref, }, ), ), @@ -911,50 +907,42 @@ Screen( id="DETAILS", title="Your details", - data={ - "city": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - }, - }, - "__example__": [{"id": "1", "title": "Light City, SO"}], - } - }, + data=[ + city := ScreenData( + key="city", example=[DataSource(id="1", title="Light City, SO")] + ), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="details_form", children=[ - TextInput( + name := TextInput( label="Your name", input_type=InputType.TEXT, name="name", required=True, ), - TextInput( + address := TextInput( label="Street address", input_type=InputType.TEXT, name="address", required=True, ), - Dropdown( + city := Dropdown( label="City, State", name="city", - data_source=DataKey("city"), + data_source=city.data_key, required=True, ), - TextInput( + zip_code := TextInput( label="Zip code", input_type=InputType.TEXT, name="zip_code", required=True, ), - TextInput( + country_region := TextInput( label="Country/Region", input_type=InputType.TEXT, name="country_region", @@ -965,11 +953,11 @@ on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={ - "name": FormRef("name"), - "address": FormRef("address"), - "city": FormRef("city"), - "zip_code": FormRef("zip_code"), - "country_region": FormRef("country_region"), + "name": name.form_ref, + "address": address.form_ref, + "city": city.form_ref, + "zip_code": zip_code.form_ref, + "country_region": country_region.form_ref, }, ), ), @@ -981,45 +969,37 @@ Screen( id="COVER", title="Your cover", - data={ - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - "description": {"type": "string"}, - }, - }, - "__example__": [ - { - "id": "1", - "title": "Fire and theft", - "description": "Cover your home against incidents of theft or accidental fires", - }, - { - "id": "2", - "title": "Natural disaster", - "description": "Protect your home against disasters including earthquakes, floods and storms", - }, - { - "id": "3", - "title": "Liability", - "description": "Protect yourself from legal liabilities that occur from accidents on your property", - }, + data=[ + options := ScreenData( + key="options", + example=[ + DataSource( + id="1", + title="Fire and theft", + description="Cover your home against incidents of theft or accidental fires", + ), + DataSource( + id="2", + title="Natural disaster", + description="Protect your home against disasters including earthquakes, floods and storms", + ), + DataSource( + id="3", + title="Liability", + description="Protect yourself from legal liabilities that occur from accidents on your property", + ), ], - } - }, + ), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="cover_form", children=[ - CheckboxGroup( + options_form := CheckboxGroup( name="options", - data_source=DataKey("options"), + data_source=options.data_key, label="Options", required=True, ), @@ -1027,7 +1007,7 @@ label="Continue", on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, - payload={"options": FormRef("options")}, + payload={"options": options_form.form_ref}, ), ), ], @@ -1039,31 +1019,22 @@ id="QUOTE", title="Your quote", terminal=True, - data={ - "excess": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - }, - }, - "__example__": [{"id": "1", "title": "$250"}], - }, - "total": {"type": "string", "__example__": "$47.98 per month"}, - }, + data=[ + excess := ScreenData( + key="excess", example=[DataSource(id="1", title="$250")] + ), + total := ScreenData(key="total", example="$47.98 per month"), + ], layout=Layout( type=LayoutType.SINGLE_COLUMN, children=[ Form( name="quote_form", - init_values={"payment_options": "1"}, children=[ Dropdown( label="Excess", name="excess", - data_source=DataKey("excess"), + data_source=excess.data_key, on_select_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={"excess": FormRef("excess")}, @@ -1084,9 +1055,10 @@ }, ), required=True, + init_value="1", ), - TextHeading(text=DataKey("total")), - OptIn( + TextHeading(text=total.data_key), + privacy_policy := OptIn( name="privacy_policy", label="Accept our Privacy Policy", required=True, @@ -1104,7 +1076,7 @@ on_click_action=Action( name=FlowActionType.DATA_EXCHANGE, payload={ - "privacy_policy": FormRef("privacy_policy") + "privacy_policy": privacy_policy.form_ref, }, ), ), @@ -1170,9 +1142,217 @@ def test_data_channel_uri(): FlowJSON(version="3.0", data_channel_uri="https://example.com", screens=[]) +def test_empty_form(): + with pytest.raises(ValueError): + Form(name="form", children=[]) + + def test_action(): with pytest.raises(ValueError): Action(name=FlowActionType.NAVIGATE) with pytest.raises(ValueError): Action(name=FlowActionType.COMPLETE) + + +def test_form_ref(): + assert FormRef("test") == "${form.test}" + assert FormRef("test", "custom") == "${custom.test}" + assert TextInput(name="test", label="Test").form_ref == "${form.test}" + assert ( + TextInput(name="test", label="Test").form_ref_of("custom_form") + == "${custom_form.test}" + ) + + +def test_data_key(): + assert DataKey("test") == "${data.test}" + assert ScreenData(key="test", example="Example").data_key == "${data.test}" + + +def test_init_values(): + text_entry = TextInput(name="test", label="Test", init_value="Example") + form = Form(name="form", children=[text_entry]) + assert form.init_values == {"test": "Example"} + + # check for duplicate init_values (in the form level and in the children level) + with pytest.raises(ValueError): + TextInput( + name="test", label="Test", init_value="Example", input_type=InputType.NUMBER + ) + Form(name="form", init_values={"test": "Example"}, children=[text_entry]) + + # test that if form has init_values referred to a data_key, + # the init_values does not fill up from the .children init_value's + form_with_init_values_as_data_key = Screen( + id="test", + title="Test", + data=[ + init_vals := ScreenData(key="init_vals", example={"test": "Example"}), + ], + layout=Layout( + children=[ + Form(name="form", init_values=init_vals.data_key, children=[text_entry]) + ] + ), + ) + assert isinstance( + form_with_init_values_as_data_key.layout.children[0].init_values, str + ) + + +def test_error_messages(): + text_entry = TextInput(name="test", label="Test", error_message="Example") + form = Form(name="form", children=[text_entry]) + assert form.error_messages == {"test": "Example"} + + # check for duplicate error_messages (in the form level and in the children level) + with pytest.raises(ValueError): + TextInput(name="test", label="Test", error_message="Example") + Form(name="form", error_messages={"test": "Example"}, children=[text_entry]) + + # test that if form has error_messages referred to a data_key, + # the error_messages does not fill up from the .children error_message's + form_with_error_messages_as_data_key = Screen( + id="test", + title="Test", + data=[ + error_msgs := ScreenData(key="error_msgs", example={"test": "Example"}), + ], + layout=Layout( + children=[ + Form( + name="form", + error_messages=error_msgs.data_key, + children=[text_entry], + ) + ] + ), + ) + assert isinstance( + form_with_error_messages_as_data_key.layout.children[0].error_messages, str + ) + + +def test_screen_data(): + assert Screen( + id="test", + title="Test", + data=[ + ScreenData(key="str", example="Example"), + ScreenData(key="int", example=1), + ScreenData(key="float", example=1.0), + ScreenData(key="bool", example=True), + ], + layout=Layout(children=[]), + ) == Screen( + id="test", + title="Test", + data={ + "str": {"type": "string", "__example__": "Example"}, + "int": {"type": "number", "__example__": 1}, + "float": {"type": "number", "__example__": 1.0}, + "bool": {"type": "boolean", "__example__": True}, + }, + layout=Layout(children=[]), + ) + + # --- + + screen_1 = Screen( + id="test", + title="Test", + data=[ + ScreenData( + key="obj", + example=DataSource(id="1", title="Example"), + ) + ], + layout=Layout(children=[]), + ) + screen_2 = Screen( + id="test", + title="Test", + data={ + "obj": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + }, + "__example__": {"id": "1", "title": "Example"}, + } + }, + layout=Layout(children=[]), + ) + + flow_json = FlowJSON(screens=[screen_1, screen_2]).to_dict() + assert flow_json["screens"][0] == flow_json["screens"][1] + + # --- + + screen_1 = Screen( + id="test", + title="Test", + data=[ + ScreenData( + key="obj_array", + example=[ + DataSource(id="1", title="Example"), + DataSource(id="2", title="Example2"), + ], + ), + ScreenData( + key="str_array", + example=["Example", "Example2"], + ), + ], + layout=Layout(children=[]), + ) + screen_2 = Screen( + id="test", + title="Test", + data={ + "obj_array": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + }, + }, + "__example__": [ + {"id": "1", "title": "Example"}, + {"id": "2", "title": "Example2"}, + ], + }, + "str_array": { + "type": "array", + "items": {"type": "string"}, + "__example__": ["Example", "Example2"], + }, + }, + layout=Layout(children=[]), + ) + + flow_json = FlowJSON(screens=[screen_1, screen_2]).to_dict() + assert flow_json["screens"][0] == flow_json["screens"][1] + + # --- + + with pytest.raises(ValueError): + Screen( + id="test", + title="Test", + data=[ScreenData(key="test", example=[])], + layout=Layout(children=[]), + ) + + with pytest.raises(ValueError): + Screen( + id="test", + title="Test", + data=[ScreenData(key="test", example=ValueError)], + layout=Layout(children=[]), + )