Create reviews for Googled books (in SilverStripe)
Tags: php, silverstripe
Published: 2022-01-13 11:25:25

Today we wrap up our book review platform in SilverStripe. When we last left off we had created a search page that displayed results from querying the Google Books API. Here's what we have planned for today:  
* Secure the `/review` URL so that only logged-in users can search for books to review.
* Add a review creation/edit page.
* Add a review-listing for the homepage.
* Add a search form for the homepage.
* Tidy up the admin interface.

> The code for this article and the project so far is at https://github.com/andersbjorkland/devto-book-reviewers/tree/store-reviews  
> Note that I reference a theme called `simple` in the articles but in my repository all the modifications from that theme is located under `reviewers` template instead.


## Securing the review-route
Our plan is to allow logged on users to create reviews for books. The flow is as follows:
* A user goes to `/review` and search for a book they want to review.
* From the list of results the user selects a book to review.
* The user is redirected to `/review/book/{volumeId}` where they can create a review for the book.

In the last episode we created a controller to handle the search for books. We will use this controller to also handle the review creation page. This means that this controller should only be accessible to logged on users. While it is possible to secure each single route in SilverStripe, for our use case it is much easier to secure the entire controller. A way to do that is to check the user's privileges when the controller is called. Any SilverStripe controller has the method `init()` which is run before any of the controller's *actions* are executed, such as the `index()` method. Let's secure the entire `ReviewController` by adding the `init()` method to it:  
```php

class ReviewController extends ContentController
{
    // ...
    public function init() 
    {
        parent::init();

        if(!Permission::check('CMS_ACCESS_' . ReviewAdmin::class)) {
            return $this->redirect('/Security/login?BackURL=' . $this->request->getURL());
        }
    }
    // ....
}    
```

If you have followed along this series you may recall that we created a security group in our second article. This group was called `Reviewers` and was assigned the permission to view the `Review` admin page. This admin page (or area) is controlled by the `ReviewAdmin` class. In the database, this group is related to a permission table entry called ` CMS_ACCESS_App\Admin\ReviewAdmin`. It's this entry that `Permission::check()` looks up to see if the user has a corresponding privilege. If it does, the `init()` method does nothing else but allows for continued execution of the controller. If the user does not have the privilege, the `init()` method redirects the user to the login page. The `BackURL` parameter is set to the current URL so that the user is returned to the current page after they successfully log in.

## Creating the review page
In the last entry in this series we created the template that would list the search results. We didn't highlight the fact that we had a *Review "book-name"...* link for each result: `<a class="reviewLink" href="/review/book/{$volumeId}" title="Review &quot;{$title}&quot;">Review &quot;{$title}&quot;...</a>`. Currently the `href` is pointing straight out into the void. We will correct this by adding an *action* to the `ReviewController` to respond to requests for `/review/book/{$volumeId}`. Here's the anatomy of the request: 
* **/review** - the controller's main route.
* **/book** - the request's *Action*-parameter. A method called `book()` will be executed if it exists.
* **/{$volumeId}** - the request's *ID*-parameter. It can be any string.

### Add the book action to the controller
{% details ReviewController.php %}
```php
<?php

namespace App\Controller;

use App\Model\Author;
use App\Model\Book;
use App\Service\GoogleBookParser;
use SilverStripe\CMS\Controllers\ContentController;
use SilverStripe\Control\HTTPRequest;
use Symfony\Component\HttpClient\HttpClient;

class ReviewController extends ContentController
{
    private static $allowed_actions = [
        'index', // this action is normally inferred, let's add it explicitly
        'book'  // We add the "book" action as an allowed action. 
    ];

    public function init() 
    {
        // ...
    }

    public function index(HTTPRequest $request)
    {
        // ...
    }

    // Add the book-method. This method will be called if the user requests `/review/book/{$volumeId}`.
    public function book(HTTPRequest $request)
    {
        $volumeId = $request->param('ID'); // This retrieves the ID-parameter from the url.

        // Next we query the Google Books API to get the book details.
        $client = HttpClient::create();
        $response = $client->request('GET', 'https://www.googleapis.com/books/v1/volumes/' . $volumeId);
        $googleBook = GoogleBookParser::parse($response->toArray());

        // We create new Author objects and store them if they do not currently exist in our database.
        $authors = [];
        foreach ($googleBook["authors"] as $googleAuthor) {
            $author = Author::get()->filter(['Name' => $googleAuthor->AuthorName])->first();

            if (!$author) {
                $author = Author::create();
                $author->Name = $googleAuthor->AuthorName;

                $names = explode(" ", $googleAuthor->AuthorName);
                $author->GivenName = $names[0];
                if (count($names) > 2) {
                    $additionalName = "";

                    for ($i = 1; $i < count($names) - 1; $i++) {
                        $additionalName .= $names[$i] . " ";
                    }

                    $author->AdditionalName = $additionalName;
                }
                $author->FamilyName = $names[count($names) - 1];

                $author->write();

                $authors[] = $author;
            }
        }

        // We create a new Book object and store it in our database (if it doesn't exist already).
        $book = Book::get()->filter(['VolumeID' => $volumeId])->first();
        if (!$book) {
            $book = Book::create();
            $book->VolumeID = $volumeId;
            $book->Title = $googleBook["title"];
            $book->ISBN = $googleBook["isbn"];
            $book->Description = $googleBook["description"];

            foreach ($authors as $author) {
                $book->Authors()->add($author);
            }

            $book->write();
        }

        // We return a response which will be rendered with a new layout called 'Review'.
        return $this->customise([
            'Layout' => $this
                        ->customise([
                            'Book' => $googleBook,
                        ])
                        ->renderWith('Layout/Review'),
        ])->renderWith(['Page']);

    }

    protected function paginator($query, $count, $startIndex, $perPage): array
    {
        // ...
    }
}


```
{% enddetails %}

{% details themes\simple\templates\Layout\Review.ss %}
```twig
<div class="content-container unit size3of4 lastUnit">
    <section class="container">
        <h1>Review &quot;$Book.title&quot;</h1>
        $ReviewForm
    </section>
</div>
```
{% enddetails %}

This is the layout that is injected into the Page template. It contains the title of the book and a variable `$ReviewForm` that will be a form returned from a method called `ReviewForm` in the controller. 

### Add the review form
Let's build the `ReviewForm` method in the controller and a form submission handler:

{% details ReviewController.php %}
```php
<?php

namespace App\Controller;

// ... use statements

class ReviewController extends ContentController
{
    private static $allowed_actions = [
        'index',
        'book',
        'ReviewForm'    // We add the "ReviewForm" action as an allowed action.
    ];

    public function init() 
    {
        // ...
    }

    public function index(HTTPRequest $request)
    {
        // ...
    }

    public function book(HTTPRequest $request)
    {
        // ...
    }

    public function ReviewForm()
    {
        // The Form is rendered on a page where a ID parameter is present. We will fetch a Book object from the database based on the ID.
        $volumeId = $this->request->param('ID');
        $book = Book::get()->filter(['VolumeID' => $volumeId])->first();

        // If the current user has reviewed the book before, we will fetch the review from the database.
        // This allows us to pre-fill the form with the user's previous review.
        $currentUser = Security::getCurrentUser();
        $review = Review::get()->filter([
                'MemberID' => $currentUser->ID, 
                'BookID' => $book->ID
            ])->first();

        // We create a list of fields for the form.
        $fields = new FieldList(
            [
                // This field holds a ID for the review if it already exists. (This lets us update the review instead of creating a new one.)
                HiddenField::create(
                    'ReviewId',
                    'ReviewId',
                    $review ? $review->ID : null
                ),

                // This field holds the ID of the book. It's required so we can store a relation to the book that the review is for.
                HiddenField::create(
                    'VolumeId',
                    'VolumeId',
                    $volumeId
                ),

                // The next 3 fields are the review itself - a heading (title), a rating and a review (the text body).
                TextField::create(
                    'Title',
                    'Title',
                    $review ? $review->Title : null
                ),
                DropdownField::create(
                    'Rating',
                    'Rating',
                    [
                        '1' => 1,
                        '2' => 2,
                        '3' => 3,
                        '4' => 4,
                        '5' => 5
                    ]
                )->setValue($review ? $review->Rating : null),
                TextareaField::create(
                    'Review',
                    'Review',
                    $review ? $review->Review : null
                )
            ]
        );

        // We create a form connected to this controller and give it a name 'ReviewForm'
        // and pass the fields we created above.
        // We also create a new field list which creates a submit button named `Submit` and "connect" it with the method 'doReview'. It also controls that the required fields are filled. 
        $form = Form::create(
            $this, 
            'ReviewForm', 
            $fields, 
            new FieldList(
                FormAction::create('doReview', 'Submit')), 
                new RequiredFields('Title', 'Rating'));

        // Normally the form what suffice as it is if this was based on a Page-object, but we are not, so we will set the form-action to the URL that will handle the form submission instead.
        $form->setFormAction('/review/ReviewForm/');

        return $form;
    }

    // This is the method tasked with handling the form submission.
    public function doReview($data, Form $form)
    {
        $book = Book::get()->filter(['VolumeID' => $data['VolumeId']])->first();

        // If a review already exists, the form will have a ReviewId field with the ID for it. Otherwise we create a new review.
        $review = $data['ReviewId'] ? Review::get_by_id($data['ReviewId']) : Review::create();

        // We set the review's properties based on the form data and save it.
        $review->Title = $data['Title'];
        $review->Rating = $data['Rating'];
        $review->Review = $data['Review'];
        $review->Member = Security::getCurrentUser();
        $review->Book = $book;
        $review->write();

        $form->sessionMessage('Your review has been saved', 'good');
        return $this->redirectBack();
    }

    protected function paginator($query, $count, $startIndex, $perPage): array
    {
        // ...
    }
}
```
{% enddetails %}

What have we done so far? Let's have a look 👇
![Create a review](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/788gz0pohrpoilbasg2j.gif)  

## Review listing on homepage
We have made it so much easier to create a review. Now we need to make it so that the review is shown on the homepage. We are going to create a new Page-type that we can associate with a particular controller and in extension with a particular layout. We can then edit the homepage in the CMS to use the new Page-type.  
  
### The Homepage Page-type
Let's create a new Page-type called `Homepage`. This will be a page that uses a specific controller that we build in the next step (the `HomepageController`).

{% details app\src\Page\Homepage.php %}
```php
<?php

namespace App\Page;

use App\Controller\HomepageController;
use Page;

class Homepage extends Page
{
    public function getControllerName()
    {
        return HomepageController::class;
    }
}
```
{% enddetails %}

### The Homepage Controller
Let's create a new controller called `HomepageController` that will handle the homepage (or the Page-type associated with it). It will at first only be associated with the inferred `index`-action and contain one method to be accessed from the `homepage` layout (which we will build in the next step). The method is `LatestReviews` which will fetch the 5 latest reviews created.

{% details app\src\Controller\HomepageController.php %}
```php
<?php

namespace App\Controller;

use App\Model\Review;
use SilverStripe\CMS\Controllers\ContentController;

class HomepageController extends ContentController
{
    public function LatestReviews()
    {
        $reviews = Review::get()->sort('Created', 'DESC')->limit(5);
        return $reviews;
    }

}
```
{% enddetails %}

### The Homepage Layout
Let's create a new layout called `homepage` that will be used by the `Homepage` Page-type. It will display the latest reviews and their ratings.

{% details themes\simple\templates\App\Page\Layout\Homepage.ss %}
```twig
<section class="container">
    <h1>The Home of Book Reviewers</h1>
    <div class="line">
        <br>
        <h2>Latest Reviews</h2>
        <% loop $LatestReviews %>
            <div>
                <h3>$Book.Title</h3>
                <p>
                    <b>$Title</b> <br>
                    $Review.FirstParagraph <br>
                    $RatingStars
                </p>
            </div>
        <% end_loop %>
    </div>
</section>
```
{% enddetails %}

### Modify the Review model
We want to get the rating in a review as the number of stars. We can do this by adding a new method to the `Review` model that will do this for us:

{% details app\src\Model\Review.php %}
```php

class Review extends DataObject
{
    // ...

    public function getRatingStars()
    {
        $rating = $this->Rating;
        $stars = '';
        for ($i = 1; $i <= $rating; $i++) {
            $stars .= '⭐';
        }
        return $stars;
    }

}

```
{% enddetails %}

### Editing the homepage in the CMS
The next step is for us to log into the CMS at `/admin` with administrator privileges and edit the homepage. On the pages-segment (`/admin/pages`), click on `Home` and then on the tab `Settings` in the upper right corner. The first setting is the `Page Type`. Currently set to `Page` but we want to change it to `Homepage`. Alas, `Homepage` does not appear in the drop down list. The reason is that we have adjusted something that needs to be updated in the database. In the browser, visit `/dev/build` to update the database with our new Page-type. Then go back to the `Home` page and click on the `Settings` tab again. The drop down list now contains `Homepage`. Select it! Then click `Save`, and then `Publish`. Going to the homepage in the browser will now show the new homepage. If you have created a few reviews, it might look something like this:

![Homepage with a listing of books with their titles, the first paragraph of a review and rating.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ewke700byf7uzmspk5lt.png)  

## Make the reviews searchable
We have made it so that the reviews are shown on the homepage. Now we need to make it so that the reviews can be searched. We will expand the `HomepageController` and the `Homepage.ss` layout to include a search field. When submitting it will redirect to a book search result page.

### Improve the HomepageController
We want to add a form so we can search for books. We will add it with the `SearchBookForm` method in the `HomepageController`:

{% details app\src\Controller\HomepageController.php %}
```php
<?php

namespace App\Controller;

use App\Model\Review;
use SilverStripe\CMS\Controllers\ContentController;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\TextField;

class HomepageController extends ContentController
{
    // Add the form as an allowed action
    private static $allowed_actions = [
        'SearchBookForm'
    ];

    public function LatestReviews()
    {
        // ...
    }

    // Create a form for GET-requests.
    // This form will be pre-filled with the search query if it exists and submissions will go to a 
    // `doSearchBook` method.
    public function SearchBookForm()
    {
        $q = $this->request->getVars()['q'] ?? '';
        $form = Form::create(
            $this,
            'SearchBookForm',
            FieldList::create(
                TextField::create('q', 'Book title', $q)
            ),
            FieldList::create(
                FormAction::create('', 'Search')  // We set no associated action method as we will handle it in the BookController)
            )
        );
        $form->setFormMethod('GET');

        // Redirect to the search results page at `/book` if the form is submitted
        $form->setFormAction('book');
        return $form;
    }

}
```
{% enddetails %}

We need to update our Book-model to include a `getAverageRating()` and `getAverageRatingStars()` methods. We won't have to run another `dev/build` as this is not a database-related change. (Naming it with the `get` prefix means that we could loop through books and access the average rating with `$AverageRating` or `$AverageRatingStars`.  

We also will add a method to cast the Book's description field to HTMLText so that Line Break elements are rendered correctly. We will be able to use `$DescriptionHTML` in the template and this will in turn render the Book's description with their HTML elements.

{% details app\src\Model\Book.php %}
```php
<?php

namespace App\Model;

use App\Admin\ReviewAdmin;
use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Permission;

class Book extends DataObject
{
    // ...

    private static $casting = [
        'DescriptionHTML' => 'HTMLText'
    ];

    public function DescriptionHTML()
    {
        return $this->Description;
    }

    public function getAverageRating()
    {
        $reviews = $this->Reviews();
        $total = 0;
        foreach ($reviews as $review) {
            $total += $review->Rating;
        }
        return $total / $reviews->count();
    }

    public function getAverageRatingStars()
    {
        $rating = $this->getAverageRating();
        $stars = '';
        for ($i = 1; $i <= $rating; $i++) {
            $stars .= '⭐';
        }
        return $stars;
    }
    
}
```
{% enddetails %}

### Improve the Homepage layout
Let's add the search form to the layout. We will see how we access from the controller:  

{% details themes\simple\templates\App\Page\Layout\Homepage.ss %}
```twig

<section class="container">
    <h1>The Home of Book Reviewers</h1>
    <div class="Actions line">
        $SearchBookForm
    </div>

    <!-- This div was as the layout originally was -->
    <div class="line">
        <br>
        <h2>Latest Reviews</h2>
        <% loop $LatestReviews %>
            <div>
                <h3>$Book.Title</h3>
                <p>
                    <b>$Title</b> <br>
                    $Review.FirstParagraph <br>
                    $Up.RatingStars($Rating)
                </p>
            </div>
        <% end_loop %>
    </div>
</section>
```
{% enddetails %}

### Create a BookController
The `BookController` will handle searches for books as well as specific views for each book. On the Book View the reviews associated with the book will be shown. We will see that our `index` action will use a `BookHolder` layout, and the `view` action will use a `BookPage` layout. We build out these layouts as well. 

{% details app\src\Controller\BookController.php %}
```php
<?php 

namespace App\Controller;

use App\Model\Book;
use SilverStripe\CMS\Controllers\ContentController;
use SilverStripe\ORM\PaginatedList;

class BookController extends ContentController
{
    private static $allowed_actions = [
        'view' // This is the action that will be called when a user clicks on a book title
    ];

    // The index action will be called when the user visits the root of the BookController at '/book'.
    // It displays a paginated book results lists for the current query.
    public function index()
    {
        // Get the current query from the request
        $search = $this->request->getVar("q") ?? '';

        // If you like to specify a sort order, we can do it like this.
        // We won't work more on this feature for this context, but it's something to improve upon.
        $sort = $this->request->getVar("sort");
        $fields = DataObject::getSchema()->fieldSpecs(Book::class);
        if (!key_exists($sort, $fields)) {
            $sort = 'Title';
        } 

        if ($search) {
            $books = Book::get()

                // Only show books that have at least one review
                ->filter([
                    'Reviews.Count():GreaterThan' => 0  
                ])

                // A rudimentary search for title or author.
                ->filterAny([
                    'Title:PartialMatch' => $search,
                    'Authors.Name:PartialMatch' => $search,
                ]);
        } else {
            $books = Book::get()->sort($sort);
        }

        return $this->customise([
            'Layout' => $this
                        ->customise([
                            'Books' => (new PaginatedList($books, $this->getRequest()))
                                        ->setPageLength(5),
                            'Query' => $search,
                        ])
                        ->renderWith('Layout/BookHolder'),
            
        ])->renderWith(['Page']);
    }

    // This action will be called when a user clicks on a book title and will display the book and its reviews (with a paginated list).
    public function view()
    {
        $book = Book::get()->filter([
            'VolumeID' => $this->request->param('ID')  // We get the ID from the URI (instead of as a query parameter)
        ])->first();


        return $this->customise([
            'Book' => $book,
            'Layout' => $this
                        ->customise([
                            'Book' => $book,
                            'Reviews' => (new PaginatedList($book->Reviews(), $this->getRequest()))
                                        ->setPageLength(5),
                        ])
                        ->renderWith('Layout/BookPage'),
        ])->renderWith(['Page']);
    }
}
```
{% enddetails %}

{% details themes\simple\templates\Layout\BookHolder.ss %}
```twig
<section class="container">
    <h1 class="text-center">Books</h1>
    
    <% include SearchBar %>

    <div id="Content" class="searchResults">

        <% if $Books %>
            <% if $Query %>
                <p class="searchQuery">Results for "$Query"</p>
            <% end_if %>
            <ul id="SearchResults">

                <% loop $Books %>
                    <li>
                        <h4>
                            <a class="reviewsLink" href="/book/view/{$VolumeID}" title="&quot;{$title}&quot;">$Title</a>
                        </h4>
                        <div>
                            <p>
                                by 
                                <% loop $Authors %>
                                    $Name<% if not $IsLast %><% if $FromEnd == 2 %> and <% else %>, <% end_if %><% end_if %>
                                <% end_loop %>
                            </p>
                            <p>$AverageRatingStars ($Reviews.Count)</p>
                        </div>                 
                    </li>
                <% end_loop %>

            </ul>
            <% if $Books.MoreThanOnePage %>
                <% if $Books.NotFirstPage %>
                    <a class="prev" href="$Books.PrevLink">Prev</a>
                <% end_if %>
                <% loop $Books.PaginationSummary %>
                    <% if $CurrentBool %>
                        $PageNum
                    <% else %>
                        <% if $Link %>
                            <a href="$Link">$PageNum</a>
                        <% else %>
                            ...
                        <% end_if %>
                    <% end_if %>
                <% end_loop %>
                <% if $Books.NotLastPage %>
                    <a class="next" href="$Books.NextLink">Next</a>
                <% end_if %>
            <% end_if %>
        <% end_if %>
    </div>
</section>
```
{% enddetails %}

{% details themes\simple\templates\Layout\BookPage.ss %}
```twig
<section class="container">
    <h1 class="text-center">$Book.Title</h1>
    <div id="content">
        <div>$Book.AverageRatingStars</div>
        <br>
        <p>
            by 
            <% loop $Book.Authors %>
                $Name<% if not $IsLast %><% if $FromEnd == 2 %> and <% else %>, <% end_if %><% end_if %>
            <% end_loop %>
        </p>
        <p>
            $Book.DescriptionHTML
        </p>
        <br>
    </div>
    <div id="reviews" class="searchResults">

        <% if $Reviews %>
            <p class="searchQuery">Current Reviews</p>
            <ul id="SearchResults">

                <% loop $Reviews %>
                    <li>
                        <h4>$Title</h4>
                        <div>
                            <p>by $Member.FirstName</p>
                            <p>$RatingStars</p>
                        </div>                 
                        <p>$Review.FirstParagraph</p>
                    </li>
                <% end_loop %>

            </ul>
            <% if $Reviews.MoreThanOnePage %>
                <% if $Reviews.NotFirstPage %>
                    <a class="prev" href="$Reviews.PrevLink">Prev</a>
                <% end_if %>
                <% loop $Reviews.PaginationSummary %>
                    <% if $CurrentBool %>
                        $PageNum
                    <% else %>
                        <% if $Link %>
                            <a href="$Link">$PageNum</a>
                        <% else %>
                            ...
                        <% end_if %>
                    <% end_if %>
                <% end_loop %>
                <% if $Reviews.NotLastPage %>
                    <a class="next" href="$Reviews.NextLink">Next</a>
                <% end_if %>
            <% end_if %>
        <% end_if %>
    </div>
</section>
```
{% enddetails %}

And before we test this out we want to add a route to our new controller, and add a navigation link for it.

{% details app\_config\routes.yml %}

We add a route for 'book' with an associated controller. Remember to run `?flush` in the browser after updating routes.
```yaml
---
Name: approutes
After: framework/_config/routes#coreroutes
---
SilverStripe\Control\Director:
  rules:
    'review': 'App\Controller\ReviewController'
    'book': 'App\Controller\BookController'
```
{% enddetails %}

{% details themes\simple\templates\Includes\Navigation.ss %}
```twig
<nav class="primary">
	<span class="nav-open-button">²</span>
	<ul>
		<% loop $Menu(1) %>
			<% if $MenuTitle.XML == "Registration" %>
				<% if not $CurrentMember %>
					<li class="$LinkingMode"><a href="$Link" title="$Title.XML">$MenuTitle.XML</a></li>
				<% end_if %>
			<% else %>
				<li class="$LinkingMode"><a href="$Link" title="$Title.XML">$MenuTitle.XML</a></li>
			<% end_if %>
		<% end_loop %>

		<%-- Add our new 'Book' link here --%>
		<li class="<% if $URLSegment == "App\Controller\BookController" %>current<% end_if %>"><a href="/book" title="Books">Books</a></li>


		<% if $CurrentMember %>
			<li class="<% if $URLSegment == "App\Controller\ReviewController" %>current<% end_if %>"><a href="/review" title="Review">Review</a></li>
			<li><a href="$AdminURL" title="Admin">Admin</a></li>
			<li><a href="/Security/logout?BackURL=/" title="Logout">Logout</a></li>	
		<% else %>
			<li><a href="/Security/login?BackURL=/" title="Login">Login</a></li>	
		<% end_if %>
		
	</ul>
</nav>

```
{% enddetails %}

## Tidying it up
Our navigation bar is a bit messy, so we'll tidy it up. We need to log in to the admin area and remove the pages that we are not using. We are not using `About Us` or `Contact Us` so we'll remove them. 

In the `Pages` section, click on the `Batch Actions` checkbox. Then we check the boxes for the two pages `About Us` and `Contact Us`. In the dropdown menu, select `Unpublish and archive`. We can now click on `Go`. This will remove the pages from the site and from our navigation bar.

While we are tidying up, let's also give our website a title. Click on the `Settings` section and fill out the form.
`Site title`: Book Reviewers
`Site Tagline`: The best place to find books
Then let's click on `Save`.

## All done
**HEY!** Look how far we have come. This is amazing 🤩
![Search for a book or author in the search field on the homepage](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u1dpecw6s6hkvlhbi7u1.gif)