tl;dr
Manually built metrics in Nova don’t support the time-range dropdown that the Nova helper functions support, and the Nova helper functions don’t handle many<->many
relationships well (at least not the way I needed).
To get around this, you can use two protected functions from the parent Value
class: currentRange()
and previousRange()
. Just don’t forget to pass in the current admin’s timezone!
Just looking for a code snippet? Jump to the end.
Quick Links
- Background (What does the metric class look like?)
- Problem (Where I got stuck)
- How I Solved: Looking Under the Hood (How I got unstuck)
- My Workaround (my code)
Background
Metric cards in Nova can be produced rapidly, are straightforward to plan and explain, and provide high-impact ‘quick wins’ when using Nova to build out a dashboard for a Laravel application.
Class Structure
Value metrics generally consist of four methods: calculate()
, ranges()
, cacheFor()
, and uriKey()
. We’re only concerned with the first two.
The ranges
method ultimately populates the dropdown on the frontend. It returns an array of time ranges your metric will support, and is pre-populated by Artisan. If you don’t want to support ranges, you can remove this method; it’s actually optional, and you can add/remove ranges from the returned array as you see fit.
The Calculate Method
Metrics are centered around that calculate
method, which can frequently be a one-liner. The parent class for ranged metrics includes methods for the most frequent queries you’d want to make (count, sum, max, min, and average), so as long as you’re creating a metric for an eloquent model — say, users
— Nova (and Artisan) will do most of the work for you. The calculate method for metric measuring how your app is growing might look like this:
use App\User;
public function calculate(NovaRequest $request) {
return $this->count( $request, User::class );
}
Code language: PHP (php)
and would return the number of users your app has acquired (over a given range), along with a percent increase or decrease compared to the the previous period. You can also further specify your query for users matching a particular set of rules (for example, if your users
had an account_status
, you could alter the return statement like so:
public function calculate(NovaRequest $request) {
$this->count( $request, User::where('account_status', 'active'))
}
Code language: PHP (php)
The metric looks pretty good on the frontend out of the box, too:
Note the range dropdown in the upper right; this is where the ranges()
method comes in — you can choose what options appear in that dropdown by setting them in that method (or, if you’re happy with the default options that are pre-populated, don’t worry about it!). The actual implementation of this feature seems to be Nova magic.
This is great for simple metrics like counting how many active users there are in your application, or counting the number of posts that were published, but what if you want to report on a metric that isn’t covered by Nova helper functions?
Manually Building Results Values
Manually building results is equally straightforward at first glance. Nova metrics support manually building result values. It even supports including reporting previous result values, so long as you calculate them yourself:
public function calculate(NovaRequest $request) {
$result = //some query
$previous = //another query (optional)
return $this->result($result)->previous($previous);
}
Code language: PHP (php)
This works well for reporting a value in one time range, but when you build your results manually, you lose the ability to dynamically compare the metric across different time ranges (see the dropdown in the upper right of the screenshot above). What if you need that dropdown?
Problem
We needed to return a count of the records in the pivot table (in this case, we have a badges table and users , and need to report the total number of badges earned (by all users, over a time range).
Building that result manually is easy enough: We can use Laravel’s database facade to count the records in the user_badges
pivot table:
$result = DB::table('user_badges') ->count();
Code language: PHP (php)
We can even compare to a previous value if we calculate it ourselves, but it won’t connect to the ranges()
method, so this only works if we hardcode a fixed time range. What about that dropdown?
I was unable to find anything in the documentation on how to handle ranges if building the result values manually, especially to support the dropdown that seems to be Nova magic for straightforward metrics. Fortunately, we can look at how Nova’s metric helper functions are written for ideas. There is an answer in the source code!
Looking Under the Hood: How Nova implements Metrics classes
The metrics classes we generate with Artisan extend the abstract class Value
. This class contains the helper methods you use for simple metrics. There isn’t a whole lot happening in these helpers, however. They’re all one-liners that call a protected method. It’s that protected method, aggregate()
, that’s of interest to us:
protected function aggregate($request, $model, $function, $column = null, $dateColumn = null)
{
$query = $model instanceof Builder ? $model : (new $model)->newQuery();
$column = $column ?? $query->getModel()->getQualifiedKeyName();
$timezone = Nova::resolveUserTimezone($request) ?? $request->timezone;
$previousValue = round(with(clone $query)->whereBetween(
$dateColumn ?? $query->getModel()->getCreatedAtColumn(),
$this->previousRange($request->range, $timezone)
)->{$function}($column), $this->precision);
return $this->result(
round(with(clone $query)->whereBetween(
$dateColumn ?? $query->getModel()->getCreatedAtColumn(),
$this->currentRange($request->range, $timezone)
)->{$function}($column), $this->precision)
)->previous($previousValue);
}
Code language: PHP (php)
This method may look like a lot, but at a bird’s eye view, it’s doing something that’s already documented, both in this post and in Nova’s official docs – it’s manually building a result and a previous value, and returning them! To do this, it’s using two more protected helper methods: currentRange()
and previousRange()
. So when we manually build results in our metrics class, we’re overriding these!
It follows that we can do the same in our class to support time ranges. However, that method takes two inputs, which we must remember to pass in ourselves: the timezone of the current user (conveniently calculated in the third line of the aggregate
method above) and the time range (which even more conveniently is passed in as part of the request).
So, the strategy is to use these two helper functions to help manually build our result.
My Final Calculate Function
public function calculate(NovaRequest $request) {
$timezone = Nova::resolveUserTimezone($request) ?? $request->timezone;
$result = DB::table('user_badges')
->whereBetween( 'created_at', $this->currentRange($request->range, $timezone) )
->count();
$previous = DB::table('user_badges')
->whereBetween( 'created_at', $this->previousRange($request->range, $timezone) )
->count();
return $this->result($result)->previous($previous);
}
Code language: PHP (php)
This approach, in combination with the built in ranges()
method, successfully counts the number of records in the pivot table that were created within the time range selected on the front end.