DEV Community

Anders Björkland
Anders Björkland

Posted on

Create reviews for Googled books (in SilverStripe)

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:


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());
        }
    }
    // ....
}    
Enter fullscreen mode Exit fullscreen mode

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

ReviewController.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
    {
        // ...
    }
}


Enter fullscreen mode Exit fullscreen mode

themes\simple\templates\Layout\Review.ss
<div class="content-container unit size3of4 lastUnit">
    <section class="container">
        <h1>Review &quot;$Book.title&quot;</h1>
        $ReviewForm
    </section>
</div>
Enter fullscreen mode Exit fullscreen mode

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:

ReviewController.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
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

What have we done so far? Let's have a look 👇
Create a review

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).

app\src\Page\Homepage.php
<?php

namespace App\Page;

use App\Controller\HomepageController;
use Page;

class Homepage extends Page
{
    public function getControllerName()
    {
        return HomepageController::class;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

app\src\Controller\HomepageController.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;
    }

}
Enter fullscreen mode Exit fullscreen mode

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.

themes\simple\templates\App\Page\Layout\Homepage.ss
<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>
Enter fullscreen mode Exit fullscreen mode

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:

app\src\Model\Review.php

class Review extends DataObject
{
    // ...

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

}

Enter fullscreen mode Exit fullscreen mode

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.

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:

app\src\Controller\HomepageController.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;
    }

}
Enter fullscreen mode Exit fullscreen mode

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.

app\src\Model\Book.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;
    }

}
Enter fullscreen mode Exit fullscreen mode

Improve the Homepage layout

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

themes\simple\templates\App\Page\Layout\Homepage.ss

<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>
Enter fullscreen mode Exit fullscreen mode

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.

app\src\Controller\BookController.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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

themes\simple\templates\Layout\BookHolder.ss
<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>
Enter fullscreen mode Exit fullscreen mode

themes\simple\templates\Layout\BookPage.ss
<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>
Enter fullscreen mode Exit fullscreen mode

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

app_config\routes.yml

We add a route for 'book' with an associated controller. Remember to run ?flush in the browser after updating routes.

---
Name: approutes
After: framework/_config/routes#coreroutes
---
SilverStripe\Control\Director:
  rules:
    'review': 'App\Controller\ReviewController'
    'book': 'App\Controller\BookController'
Enter fullscreen mode Exit fullscreen mode

themes\simple\templates\Includes\Navigation.ss
<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>

Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)