DEV Community

Bruno Oliveira
Bruno Oliveira

Posted on • Edited on

Flask series part 8: Improving user experience by using input chips and a dropdown menu

Introduction

Last time, on a previous installment in our series, we focused on vastly improving the way we handled input, by creating a dropdown menu with autocomplete, so that users could select ingredients in a much more streamlined way.

Now, we will extend this functionality by adding input chips and extra responsiveness to our application.

When we will be done, users will be able to select combinations of ingredients again, via selecting multiple ingredients, as well as removing items in any order, allowing them to experiment with multiple combinations of input.

This is what it will look like:

better input

In this example, we are seeing recipes containing both chicken and cheese as their ingredients, and, we can choose to click on either of the chips to remove it, while having the search being updated again with the new set of "chip ingredients".

How to link it all together from what we have now

This is an interesting feature, because it allows for some space for architecturing and designing a good flow. There are many ways to go about this when using different stacks and/or ways of structuring code:

  • Use an eventBus to "bridge" components together: Google's Guava EventBus is a mechanism of enabling communication between seeminglessly disconnected components. There is a global object, the eventBus instance, that enables classes to register themselves as senders of events via a bus, and receivers need to implement a certain kind of callback method to receive values passed through the bus.

It's an efficient, practical and yet dangerous solution, since the eventBus basically bypasses any existing boundaries imposed by the current code structure and it can get out of hand very fast.

  • Using standard composition and similar design patterns like Observer. Components are hierarchically organized and design patterns make the best of such hierarchies, enabling safe, predictable communication between components.

  • We can use REST APIs and server requests to enable communication between the front-end and a server, that handles the requests and keeps state, transferring the state to the front-end via HTML templates as JSON that gets updated as a response to user interactions on the front-end.

The last approach is the one we will take, since we are developing a RESTful web application and our front-end is leveraging HTML and JS. It's also a very standard approach that allows for lots of experimentation and possibilities in terms of its architecture. Here is a diagram of our flow:

diagram

Let's go over it, so we have a better idea of the current code flow at the moment:

It all starts in the front-end, where the user types in the search bar for ingredients to get recipes for.

Then, we bind a callback function on click, that will be responsible to perform an AJAX request to our Flask server, to post our JSON data to a specific endpoint. There we will store the ingredient that the user entered as input in the two tables in our database: the SearchSuggestion and the IngredientChip tables which will be responsible for presenting the top five most recent searches to all the users and for storing the search suggestions typed by the user to both drive the search and build the UI component.
With the results stored in the database, we send a successful response to the front-end, in order to trigger the onSuccess callback of the AJAX request, which is executed after the successful response is received asynchronously.

There, we use jQuery to force a click on our search button which then posts the content that is in the form to our main endpoint, that was driving our search previously. Now, such functionality is gone, and only the dropdown is driving the search, through an AJAX call first to save the entered ingredient in the database, and, on the response, by forcing a click on the search button that uses the previously stored information to build the JSON content that will power the Jinja templates.

After the JSONs are prepared and built, we use the Spoonacular API to retrieve the recipes that contain the ingredients in the database, build a response JSON containing all the recipes, and the render_template method will ensure that the templates are correctly built and afterwards rendered.

Single responsibility principle

The single responsibility principle states that a class, method or function should do one thing and one thing only.

This principle can also be applied to our current API design.

For our case, the resulting template rendering containing the list of recipes comprised from the entered ingredients is handled now only and exclusively only by the dropdown component, via clicking on an entry there. It's the only way to generate recipes.

This is seen by the fact that our main endpoint now looks like this:

@app.route('/', methods=['GET', 'POST'])
def get_recipe():
    ingredients_searched = []
    chip_ingredients = []
    suggestions_as_json = {"searches": ingredients_searched}
    chip_ingredients_as_json = {"chipIngredients": chip_ingredients}
    if request.method == 'POST':
        search_suggestions = select(prod for prod in SearchSuggestion).order_by(lambda prod: desc(prod.id))[:5]
        for entry in search_suggestions:
            ingredients_searched.append(entry.ingredient)
        suggestions_as_json = {"searches": ingredients_searched}

        ing_suggestions = select(prod for prod in IngredientChip)
        for entry in ing_suggestions:
            chip_ingredients.append(entry)
        chip_ingredients_as_json = {"chipIngredients": chip_ingredients}

        ingredients = ",".join(map(lambda db_entry: db_entry.ingredient, chip_ingredients))
        content = requests.get(
            "https://api.spoonacular.com/recipes/findByIngredients?ingredients=" +
            convert_input(ingredients) +
            "&apiKey=" + API_KEY)
        json_response = json.loads(content.text)
        print json_response
        return render_template("recipes_list.html", ans=json_response, searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json) if json_response != [] else render_template(
            "recipes_list.html", ans="", searchHistory=suggestions_as_json, chipIngredients=chip_ingredients_as_json)
    else:
        delete(ingredient for ingredient in IngredientChip)
        return render_template("recipes_list.html", searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json)

It's still complex, and it will be simplified further, but, as we can see, we simply use the ingredients from the database, build the templates and send these results to the front-end, with the whole interaction with the ingredients entered by the user happening on a different endpoint.

This endpoint now uses the values computed via a separate endpoint to get the recipes we want.

This results in an increase of both functionality and endpoint reusability:

  • when adding/removing ingredients from the chip items list, the main endpoint will be responsible solely for: reading from the db and performing a search with the values it retrieves. It knows nothing about adding or removing ingredients. That functionality is delegated to other endpoints now.

  • since the recipes calculation happens via the database, we always perform the request to the Spoonacular API with the latest "snapshot" of the database. This translates into an amazing experience for the end user, since it will all appear and feel instantaneous and interactive. Add cheese, add meat, remove meat, see all recipes for cheese, with all the flow being handled via the database, which is much more reliable than the comma-separated string we were entering before.

  • effectively increases usability as well, since we gain back the feature of searching for recipes containing multiple ingredients again, which was something we had lost after our refactor to introduce the autocomplete component.

This last point illustrates a vital idea in development:

Over time, functionalities will change, adapt and grow, regressions will be introduced. The trick is to have an end-goal and certain architectural patterns in mind that allow you to get where you want to be.

Add the chips to the UI

To add the chips to the UI, we first define our placeholder component in the HTML Jinja template:

<div class='col-xs-12 col-sm-12 col-md-10 col-lg-10'>
    <div id="optionalIngredients">
        {% for entry in chipIngredients['chipIngredients'] %}
        <button id="chipButton" type=button class="btn btn-default" onclick="deleteEntry({{entry['id']}})">{{
            entry['ingredient']+' x'}}
        </button>
        {% endfor %}
    </div>
</div>

This iterates over the list of chipIngredients we have, and makes a button out of each entry, to which we bind the deleteEntry function, that does a DELETE request via AJAX to delete the current entry from the DB:

function deleteEntry(id){
    $.ajax({
        type: 'DELETE',
        url: '/deleteIngredient/' + id,
        success: function () {
            $('#searchBtn').trigger("click");
        },
        fail: function () {
            console.log("Error in deleting")
        }
    });
}

On success, we trigger a new search which will then be done only with the currently available ingredients, as explained on the diagram above.

Note that all of the chipIngredients come from the database table ChipIngredient that is managed via two separate endpoints for adding and deleting items from it (using the item's ID), and where our main "homepage" endpoint handles the querying and selection of these items to build the template that will ultimately manage and drive the UI component.

Finally, we add the CSS to the chip component:

#chipButton {
    display: inline-block;
    padding: 0 10px;
    height: 25px;
    font-size: 14px;
    line-height: 18px;
    border-radius: 25px;
    background: #e8a444;
    color: white;
}

And we are done!

Conclusion

It's very important to realize that, sometimes, some features can only be achievable through conscious code cleaning and refactoring that will lay the foundations that will allow you to develop a good architecture, that can both:

  • Be achievable with the current state of the codebase;

  • Get your application where you want it to be, while at the same time allowing for further refinements and improvements;

The last point is arguably very difficult to achieve without some hard architectural work that needs to be able to "predict the future", i.e., the code needs to be flexible enough to be extended and reusable, if applicable.

It's one of the main tasks of software engineering that gets materialized on a daily basis, as streams of requests, enhancements, features and bug fixes keep appearing, all the while steering both the code and the product along certain axes. It's our job to ensure that these are the correct axes. It can be hard, but it is also very rewarding!

Stay tuned for more, and, thanks for reading!

Top comments (0)