Pizza order exampleΒΆ

Example illustrating apps that involve multiple stages

import enum
import json
import time
import threading

import traitlets
import ipywidgets as ipw

from aiidalab_widgets_base import WizardAppWidget
from aiidalab_widgets_base import WizardAppWidgetStep


OrderStatus = enum.Enum(
    "OrderStatus",
    {"init": 0, "in_preparation": 40, "in_transit": 65, "delivered": 100, "unavailable": -1}
)
    

class OrderTracker(traitlets.HasTraits):
    """Helper class to keep track of our pizza order."""

    configuration = traitlets.Dict()  # the pizza configuration that was ordered
    status = traitlets.Enum(OrderStatus)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        def simulate_order():
            self.status = OrderStatus.in_preparation
            time.sleep(5)

            # We simulate a runtime failure by pretending that "anchovies" are out-of-stock.
            if "anchovies" in self.configuration.get("toppings", []):
                self.status = OrderStatus.unavailable
                return

            self.status = OrderStatus.in_transit
            time.sleep(3)
            self.status = OrderStatus.delivered

        threading.Thread(target=simulate_order).start()
            
    
    @traitlets.default("status")
    def _default_status(self):
        """Initialize the initial (default) order status."""
        return OrderStatus.init
    
    
class OrderProgressWidget(ipw.HBox):
    """Widget to nicely represent the order status."""
    
    status = traitlets.Instance(OrderStatus)

    def __init__(self, **kwargs):
        self._progress_bar = ipw.FloatProgress(style={"description_width": "initial"}, description="Delivery progress:")
        self._status_text = ipw.HTML()
        super().__init__([self._progress_bar, self._status_text], **kwargs)
        
    @traitlets.observe("status")
    def _observe_status(self, change):
        with self.hold_trait_notifications():
            if change["new"]:
                self._status_text.value = {
                    OrderStatus.init: "Order received.",
                    OrderStatus.in_preparation: "In preparation",
                    OrderStatus.in_transit: "Driver is on their way.",
                    OrderStatus.delivered: "Delivered! :)",
                    OrderStatus.unavailable: "Your order is not available",
                }.get(change["new"], change["new"].name)
                
                self._progress_bar.value = change["new"].value
                self._progress_bar.bar_style = {
                    OrderStatus.delivered: "success",
                    OrderStatus.unavailable: "danger",
                }.get(change["new"], "info")
            else:
                self._status_text.value = ""
                self._progress_bar.value = 0
                self._progress_bar.style = "info"


class ConfigureOrderStep(ipw.HBox, WizardAppWidgetStep):

    disabled = traitlets.Bool()
    configuration = traitlets.Dict(allow_none=True)

    def __init__(self, **kwargs):
        # Setup widgets for the pizza configuration
        self.style = ipw.RadioButtons(
            options=["Neapolitan", "Chicago", "New York-Style", "Detroid"],
            description="Style:",
            value=None,
        )
        self.style.observe(self._update_state, ["value"])

        self.toppings = ipw.SelectMultiple(
            options=["pepperoni", "pineapple", "anchovies"],
            description="Toppings:",
        )
        self.toppings.observe(self._update_state, ["value"])

        # Clicking on the "Confirm configuration" button locks the
        # current configuration and enables the "order" step.
        # The pizza configuration is exposed as the "configuration" trait.
        self.confirm_button = ipw.Button(
            description="Confirm configuration", disabled=True
        )
        self.confirm_button.on_click(self._confirm_configuration)

        # We need to update the step's state whenever the configuration is changed.
        self.observe(self._update_state, ["configuration"])

        super().__init__([self.style, self.toppings, self.confirm_button], **kwargs)

    def _confirm_configuration(self, button):
        "Confirm the pizza configuration and expose as trait."
        self.configuration = dict(style=self.style.value, toppings=self.toppings.value)
        
    def reset(self):
        with self.hold_trait_notifications():
            self.style.value = None
            self.toppings.value = []
            self.configuration = {}
            
    @traitlets.default("state")
    def _default_state(self):
        return self.State.READY

    def _update_state(self, _=None):
        """Update the step's state based on traits and widget state.

        The step state influences the representation of the step (e.g. the "icon") and
        whether the "Next step" button is enabled.
        """

        if self.configuration:
            # The configuration is non-empty, we can move on to the next step.
            self.state = self.State.SUCCESS
        elif self.style.value and self.toppings.value:
            # Both style and topping selection has been made, the step is considered
            # to be in the "configured" state. This enables the "Confirm configuration"
            # button.
            self.state = self.State.CONFIGURED
        else:
            # In all other cases the step is always considered to be in the "init" state.
            self.state = self.State.READY

    @traitlets.observe("state")
    def _observe_state(self, change):
        # Enable the confirm button in case that the pizza has been configured.
        with self.hold_trait_notifications():
            self.disabled = change["new"] == self.State.SUCCESS
            self.confirm_button.disabled = change["new"] is not self.State.CONFIGURED
        
    @traitlets.observe("disabled")
    def _observe_disabled(self, change):
        with self.hold_trait_notifications():
            for child in self.children:
                child.disabled = change["new"]


class ReviewAndSubmitOrderStep(ipw.VBox, WizardAppWidgetStep):

    # We use traitlets to connect the different steps.
    # Note that we can use dlinked transformations, they do not need to be of the same type.
    configuration = traitlets.Dict()

    # We will keep track of the order status with this Enum trait.
    order = traitlets.Instance(OrderTracker, allow_none=True)

    def __init__(self, **kwargs):
        # The pizza configuration is represented as a formatted dictionary.
        self.configuration_label = ipw.HTML()

        # The second step has only function: executing the order by clicking on this button.
        self.order_button = ipw.Button(description="Submit order", disabled=True)
        self.order_button.on_click(self.submit_order)

        # We update the step's state whenever there is a change to the configuration or the order status.
        self.observe(self._update_state, ["configuration", "order"])

        super().__init__([self.configuration_label, self.order_button], **kwargs)
        
    def reset(self):
        self.order = None

    @traitlets.observe("configuration")
    def _observe_configuration(self, change):
        "Format and show the pizza configuration."
        if change["new"]:
            self.configuration_label.value = f"<h4>Configuration</h4><pre>{json.dumps(change['new'], indent=2)}</pre>"
        else:
            self.configuration_label.value = (
                "<h4>Configuration</h4>[Please configure your pizza]"
            )

    def submit_order(self, button):
        "Submit the order and simulate the delivery."
        self.order = OrderTracker(configuration=self.configuration)

    def _update_state(self, _=None):
        "Update the step's state based on the order status and configuration traits."
        if self.order:  # the order has been submitted
            self.state = self.State.SUCCESS
        elif self.configuration:  # the order can be submitted
            self.state = self.State.CONFIGURED
        else:
            self.state = self.State.INIT

    @traitlets.observe("state")
    def _observe_state(self, change):
        """Enable the order button once the step is in the "configured" state."""
        self.order_button.disabled = change["new"] != self.State.CONFIGURED


class TrackOrderStep(ipw.VBox, WizardAppWidgetStep):

    # We receive the order from the previous step and then display information about
    # its state in this widget.
    order = traitlets.Instance(OrderTracker, allow_none=True)

    def __init__(self, **kwargs):
        # We will represent the current order status with this dedicated widget.
        # It is typically a good idea to strictly separate application logic and
        # its representation within the user interface.
        self.status_widget = OrderProgressWidget()

        super().__init__([self.status_widget], **kwargs)

    @traitlets.observe("order")
    def _observe_order(self, change):
        "Couple a newly received order to the status output widget."
        # Note: Technically we must unlink old orders, but this is ommitted for brevity.

        if change["new"]:
            # Order received, immediate state change:
            self.state = self.State.ACTIVE

            # Now follow the order status for subsequent state changes:
            change["new"].observe(self._update_state, ["status"])

            # Link the order status to the widget displaying it to the user:
            ipw.dlink((change["new"], "status"), (self.status_widget, "status"))
        else:
            self.state = self.State.INIT


    def can_reset(self):
        "Do not allow reset during active order processing."
        return self.state is not self.State.ACTIVE

    def _update_state(self, change=None):
        "Update the step's state based on the order status configuration traits."
        new_status = change["new"]
        
        if new_status in (OrderStatus.in_preparation, OrderStatus.in_transit):
            self.state = self.State.ACTIVE
        elif new_status is OrderStatus.delivered:
            self.state = self.State.SUCCESS
        elif new_status is OrderStatus.unavailable:
            self.state = self.State.FAIL
        else:
            self.state = self.State.INIT


# Setup all steps of the app. Setting the `auto_advance` trait to True makes it
# so that the next step is automatically selected once the previous step is
# in the "success" state.
configure_order_step = ConfigureOrderStep(auto_advance=True)
review_and_submit_order_step = ReviewAndSubmitOrderStep(auto_advance=True)
track_order_step = TrackOrderStep()


# Data that is communicated from one step to the next via traits.
ipw.dlink(
    (configure_order_step, "configuration"),
    (review_and_submit_order_step, "configuration"),
)
ipw.dlink((review_and_submit_order_step, "order"), (track_order_step, "order"))

# Setup the app by adding the various steps in order.
app = WizardAppWidget(
    steps=[
        ("Configure pizza", configure_order_step),
        ("Confirm order", review_and_submit_order_step),
        ("Track order", track_order_step),
    ]
)


# Display the app to the user.
display(app)