Laravel Nova - Custom Calculated Field

General

August 12 2019

TLDR

This turned out to be a long blog post. If you're interested in the TLDR, the code is available on GitHub:

Github

Or can be installed via composer:

composer require codebykyle/calculated-field

The Problem

Using Laravel Nova, I will often see people asking about how to make a calculated field. Today, I wanted to walk through how to make a server-side computed field, and address some of the common misconceptions about how Laravel Nova's custom fields work.

What I am looking to do is allow the user to calculate something at run-time on the Vue.js client, which fills the field with a formula which is defined in the models resource. You should be able to optionally override this field before submitting the form.

I'd like to make it accept a number, or a string, and do arbitrary calculations which are defined by the model resource. I'd also like to allow a user to put as many formula fields as they'd like on a form.

For example:

As a string:

Calculated String Field

As a number

Calculated Number Field

The Plan

I often see people trying to use various forms of code inside of their Resource.php file to try and calculate a value on the client. Often times, the expectation is that putting a formula into the field should result in it being calculated on the front end, while the user is typing. That's what we will try to replicate here.

In a nutshell, we need to hook into the values of a field in vue, and somehow get them back to the server to run a calculation, and then fill out a form.

A common misconception here is that VueJS and Laravel are communicating by default. If you are filling out something in PHP, it often times gets bundled up and sent to the client. The configuration that is done in a resource is actually primarily just making a json array from a users perspective, which is used to initialize the front end. Once it leaves the server, the communication with the server side components are discarded until a submitted form comes back. Often times, Nova makes this look like magic, but this is an important thing to keep in mind while making this field. Remember that fields, by default, end up as json arrays, which are handed to the client only when it first loads. After that, everything is done in vue. Once the form is submitted, the values come back to laravel. Once the client receives the page, VueJS takes over entirely

Here is a default basic diagram of what is happening:

Default Diagram

In our field, though, we need to insert an additional step. We want the client to send a request off to the server whenever we input into a field, and calculate its expected value, while the user is typing. Once they've got what they want, they will submit finalized values back to the server

We need to insert an additional step

A Note about VueJS

As a quick point to keep in mind, in VueJS, there is a top-down flow of data. Fields, which are at the same level, have no way of asking for another field's value without using an anti-pattern to Vue's data-flow.

This fact dictates a few things about how our field will work. In order to communicate between fields, we will make use of VueJS (and by extension, Nova's) event bus. For this, we will actually be making two custom fields instead of one. A Broadcaster field, which sends out its value, and a Listener field, which listens for the input event. This allows us to share information horizontally, which is also loosely coupled. Fields dont really need to know about each-other; if they hear something on their event channel, they will pick up the value and send it to the server. We should be geared up and have a good idea of what to do at this point.

Making the custom fields

Making a custom field in Nova is as easy as running php artisan nova:field codebykyle/calculated-field This will get us started with a basic template, where we have a Service Provider auto registered, a php file which extends Field, and three VueJS components, and finally a field.js, which registers our custom VueJS components with the Nova build pipeline.

Because we need two fields, I deleted the original field file, and create two new ones, BroadcasterField, and ListenerField. These files are the ones which are eventually converted to Json, and gets the client started.

Server Side

As mentioned earlier, these field classes are used to tell the client side which vue component to swap in and to load some initial data for the field to initialize on the client. We can optionally store in some extra functionality in these classes

BroadcasterField

We want to tell the client where to broadcast their value to, what type of data we should allow. Lets setup a basic file to track that data:

<?php

namespace Codebykyle\CalculatedField;

use Laravel\Nova\Element;
use Laravel\Nova\Fields\Field;

class BroadcasterField extends Field
{
    /**
     * The field's component.
     *
     * @var string
     */
    public $component = 'broadcaster-field';

    /**
     * The type of the field to show on the form
     * @var string
     */
    public $type = 'number';

    /**
     * BroadcasterField constructor.
     *
     * @param $name
     * @param null $attribute
     * @param callable|null $resolveCallback
     */
    public function __construct($name, $attribute = null, callable $resolveCallback = null)
    {
        parent::__construct($name, $attribute, $resolveCallback);

        $this->withMeta([
            'type' => 'number',
            'broadcastTo' => 'broadcast-field-input'
        ]);
    }

    /**
     * Set the type of the field (string, number)
     *
     * @param $type
     * @return Element
     */
    public function setType($type) : Element
    {
        return $this->withMeta([
            'type' => $type
        ]);
    }

    /**
     * Tells the client side component which channel to broadcast on
     * @param $broadcastChannel
     * @return Element
     */
    public function broadcastTo($broadcastChannel):Element
    {
        return $this->withMeta([
            'broadcastTo' => $broadcastChannel
        ]);
    }
}

Listener Field

In the listener field, we want to track what it should listen to, but also a callback which should be called when this field asks for its value. This is not passed into the Json representation of the field, but is used later. If we want to store a value for later, we can set a variable to the class instead of using withMeta. The end result in this example is the same, but with this method, we are able to use the $listenTo variable on the field if we decide to.

<?php

namespace Codebykyle\CalculatedField;

use Illuminate\Http\Request;
use Laravel\Nova\Fields\Field;

class ListenerField extends Field
{
    /**
     * The field's component.
     *
     * @var string
     */
    public $component = 'listener-field';

    /**
     * The event this fields listens for
     * @var string
     */
    protected $listensTo;

    /**
     * The function to call when input is detected
     * @var \Closure
     */
    public $calculateFunction;

    /***
     * ListenerField constructor.
     * @param $name
     * @param null $attribute
     * @param callable|null $resolveCallback
     */
    public function __construct($name, $attribute = null, callable $resolveCallback = null)
    {
        parent::__construct($name, $attribute, $resolveCallback);

        $this->listensTo = 'broadcast-field-input';

        $this->calculateFunction = function ($values, Request $request) {
            return collect($values)->values()->sum();
        };
    }

    /**
     * The channel that the client side component listens to
     * @param $channel
     * @return $this
     */
    public function listensTo($channel) {
        $this->listensTo = $channel;
        return $this;
    }

    /***
     * The callback we want to call when the field has some input
     *
     * @param callable $calculateFunction
     * @return $this
     */
    public function calculateWith(callable $calculateFunction) {
        $this->calculateFunction = $calculateFunction;
        return $this;
    }

    /***
     * Serialize the field to JSON
     * @return array
     */
    public function jsonSerialize()
    {
        return array_merge([
            'listensTo' => $this->listensTo
        ], parent::jsonSerialize());
    }
}

Usage

With that, our fields are ready to be imported into a resource. We can use the fields like this:

Number Field
<?php
use Codebykyle\CalculatedField\BroadcasterField;
use Codebykyle\CalculatedField\ListenerField;

class MyResource extends Resource
{
    public function fields(Request $request) {
        return [    
            BroadcasterField::make('Sub Total', 'sub_total'),
            BroadcasterField::make('Tax', 'tax'),

            ListenerField::make('Total Field', 'total_field')
                ->calculateWith(function (Collection $values) {
                    $subtotal = $values->get('sub_total');
                    $tax = $values->get('tax');
                    return $subtotal + $tax;
                }),
        ];
    }
}

Because we set up our defaults on the server side component, we can also leave our configuration for the field blank. The Listener Field will sum all the values it receives by default

<?php

use Codebykyle\CalculatedField\BroadcasterField;
use Codebykyle\CalculatedField\ListenerField;

class MyResource extends Resource
{
    public function fields(Request $request) {
        return [    
            BroadcasterField::make('Sub Total', 'sub_total'),
            BroadcasterField::make('Tax', 'tax'),
            ListenerField::make('Total Field', 'total_field')
        ];
    }
}
String Field
<?php
use Codebykyle\CalculatedField\BroadcasterField;
use Codebykyle\CalculatedField\ListenerField;

class MyResource extends Resource
{
    public function fields(Request $request) {
        return [    
            BroadcasterField::make('First Name', 'first_name')
                ->broadcastTo('title')
                ->setType('string'),

            BroadcasterField::make('Last Name', 'last_name')
                ->broadcastTo('title')
                ->setType('string'),

            ListenerField::make('Full Name', 'full_name')
                ->listensTo('title')
                ->calculateWith(function (Collection $values) {
                    return $values->values()->join(' ');
                }),
        ];
    }
}

Client Side

So, now we have server side components that let us register a bunch of fields in our resource, but they dont actually do anything. We need to setup the client side, which renders on the form.

Like the server side components, lets reoder the resources/js/components folder of our field to support both the broadcaster and the listener fields. Our folder structure should look like this:


├───resources
│   ├───js
│   │   │   field.js
│   │   │   
│   │   └───components
│   │       ├───broadcaster-field
│   │       │       DetailField.vue
│   │       │       FormField.vue
│   │       │       IndexField.vue
│   │       │       
│   │       └───listener-field
│   │               DetailField.vue
│   │               FormField.vue
│   │               IndexField.vue
│   │               
│   └───sass
│           field.scss
│       
└───src
    │   BroadcasterField.php
    │   FieldServiceProvider.php
    │   ListenerField.php

We need to register our client side components. Open field.js, and add the fields. Register your components with the same name that we used in our server side components. That string will determine what client side component should be loaded. However, per Nova's convention, append index-, detail-, and form- to the component name. You should have something like this:

Nova.booting((Vue, router, store) => {
    Vue.component('index-broadcaster-field', require('./components/broadcaster-field/IndexField'));
    Vue.component('detail-broadcaster-field', require('./components/broadcaster-field/DetailField'));
    Vue.component('form-broadcaster-field', require('./components/broadcaster-field/FormField'));

    Vue.component('index-listener-field', require('./components/listener-field/IndexField'));
    Vue.component('detail-listener-field', require('./components/listener-field/DetailField'));
    Vue.component('form-listener-field', require('./components/listener-field/FormField'));
})

Now we need to setup our field's vue components

The Broadcaster Field

Open broadcaster-field/FormField.vue. The broadcasters job is to publish its value onto the VueJS event bus. For this, rather than using Vue's v-model on the input, we can expand it into two different calls. One for :value which makes the text box display our value, and one for @input which is called when the field receives input.

The values we passed in from the server side component is available us in this.field inside of the vue functions, so for example, we have access to this.field.broadcastTo, and this.field.type, which corresponds to our withMeta calls from the server side component

When the field receives input, we will broadcast the value to the event bus, and then set the value on the form. We can also do some simple parsing here, if for example, it is a number, we know that this field should be casted as a number. Lets also make the broadcaster field tell us what field is sending the message.

<template>
    <default-field :field="field" :errors="errors">
        <template slot="field">
            <input
                :id="field.name"
                :type="this.field.type"
                class="w-full form-control form-input form-input-bordered"
                :class="errorClasses"
                :placeholder="field.name"
                :value="value"
                @input="setFieldAndMessage"
            />
        </template>
    </default-field>
</template>

<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'

export default {
    mixins: [FormField, HandlesValidationErrors],

    props: ['resourceName', 'resourceId', 'field'],

    methods: {
        setFieldAndMessage(el) {
            const rawValue = el.target.value;
            let parsedValue = rawValue;

            if (this.field.type === 'number') {
                parsedValue = Number(rawValue)
            }

            Nova.$emit(this.field.broadcastTo, {
                'field_name': this.field.attribute,
                'value': parsedValue
            });

            this.value = parsedValue;
        },

        /*
         * Set the initial, internal value for the field.
         */
        setInitialValue() {
            this.value = this.field.value || ''
        },

        /**
         * Fill the given FormData object with the field's internal value.
         */
        fill(formData) {
            formData.append(this.field.attribute, this.value || '')
        },

        /**
         * Update the field's internal value.
         */
        handleChange(value) {
            this.value = value
        },
    },
}
</script>

This will broadcast onto the event bus a message similar to this:

{
    "field_name": "tax_amount",
    "value": 123.32
}

The Listener Field

The listener field is a little more complicated. We need this to listen to the event bus, and when some input is detected, it shows a loading icon, goes to a URL, gets the result, and sets it to the fields value. For this, we need to track the loading state of the field, and the values of all the fields which have previously broadcast to it.

I add a listener to the field, which listens on whatever we set to $listensTo on our server side component, which runs when the field is created on the form. This starts it immediately listening.

Once a message is received, it adds it to a dictionary of fields. If we have several listeners, this.field_values will look like this:

this.field_values = {
  "tax_amount": 123,
  "sub-total": 321,
  "discount": 111.11
}

We will submit this to a route, which invokes the field's callback, which will return to us a value. Once we get the value, we will fill this field's value with the result.

We should also use lodash's debounce method here on our callback, otherwise we end up with a possible race condition on the field's callback. This method will only submit our form to the server once we stop typing for a specified amount of time. Also, we would be slamming our server with traffic for each and every keystroke, so this is generally good practice for callbacks.

Once we start loading, we will show a loading icon, and when we're done, we will set the value and hide the loading icon. Also, take note that we are making a callback here to a URL which we have yet to create. This route will be at codebykyle/calculated-field/calculate/{resource}/{field}

<template>
    <default-field :field="field" :errors="errors">
        <template slot="field">
            <div class="relative flex items-stretch">
                <input
                    :id="field.name"
                    type="text"
                    class="w-full form-control form-input form-input-bordered"
                    :class="errorClasses"
                    :placeholder="field.name"
                    v-model="value"
                />

                <div class="absolute rotating text-80 flex justify-center items-center pin-y pin-r mr-3" v-show="calculating">
                    <svg class="w-4 h-4" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M457.373 9.387l-50.095 50.102C365.411 27.211 312.953 8 256 8 123.228 8 14.824 112.338 8.31 243.493 7.971 250.311 13.475 256 20.301 256h10.015c6.352 0 11.647-4.949 11.977-11.293C48.159 131.913 141.389 42 256 42c47.554 0 91.487 15.512 127.02 41.75l-53.615 53.622c-20.1 20.1-5.855 54.628 22.627 54.628H480c17.673 0 32-14.327 32-32V32.015c0-28.475-34.564-42.691-54.627-22.628zM480 160H352L480 32v128zm11.699 96h-10.014c-6.353 0-11.647 4.949-11.977 11.293C463.84 380.203 370.504 470 256 470c-47.525 0-91.468-15.509-127.016-41.757l53.612-53.616c20.099-20.1 5.855-54.627-22.627-54.627H32c-17.673 0-32 14.327-32 32v127.978c0 28.614 34.615 42.641 54.627 22.627l50.092-50.096C146.587 484.788 199.046 504 256 504c132.773 0 241.176-104.338 247.69-235.493.339-6.818-5.165-12.507-11.991-12.507zM32 480V352h128L32 480z" class=""></path></svg>
                </div>
            </div>
        </template>
    </default-field>
</template>

<style lang="scss">
    @-webkit-keyframes rotating {
        from{
            transform: rotate(0deg);
        }
        to{
            transform: rotate(360deg);
        }
    }
    .rotating {
        animation: rotating 2s linear infinite;
    }
</style>

<script>
import { FormField, HandlesValidationErrors } from 'laravel-nova'
import _ from 'lodash'

export default {
    mixins: [FormField, HandlesValidationErrors],

    props: ['resourceName', 'resourceId', 'field'],

    created() {
        Nova.$on(this.field.listensTo, this.messageReceived)
    },

    data: () => ({
        calculating: false,
        field_values: {}
    }),

    methods: {
        messageReceived(message) {
            this.field_values[message.field_name] = message.value;
            this.calculateValue()
        },

        calculateValue: _.debounce(function () {
            this.calculating = true;

            Nova.request().post(
                `/codebykyle/calculated-field/calculate/${this.resourceName}/${this.field.attribute}`,
                this.field_values
            ).then((response) => {
                this.value = response.data.value;
                this.calculating = false;
            }).catch(() => {
                this.calculating = false;
            });
        }, 500),

        /*
         * Set the initial, internal value for the field.
         */
        setInitialValue() {
            this.value = this.field.value || ''
        },

        /**
         * Fill the given FormData object with the field's internal value.
         */
        fill(formData) {
            formData.append(this.field.attribute, this.value || '')
        },

        /**
         * Update the field's internal value.
         */
        handleChange(value) {
            this.value = value
        },
    },
}
</script>

Calculating the values

Now we've got our server side component to serialize the field, and the vue component to display it on the form. The last thing we need is that route that the listener field is connecting to.

For that, we need to add a controller and a route. The controller should look up which resource and field we are using in order to calculate the correct result.

Lets add a routes.php file and then a controller. This is how I have my files laid out:


├───resources
│   ├───js
│   │   │   field.js
│   │   │
│   │   └───components
│   │       ├───broadcaster-field
│   │       │       DetailField.vue
│   │       │       FormField.vue
│   │       │       IndexField.vue
│   │       │
│   │       └───listener-field
│   │               DetailField.vue
│   │               FormField.vue
│   │               IndexField.vue
│   │
│   └───sass
│           field.scss
│
├───routes
│       api.php
│
└───src
    │   BroadcasterField.php
    │   FieldServiceProvider.php
    │   ListenerField.php
    │
    └───Http
        └───Controllers
                CalculatedFieldController.php


Routes

In routes/api.php, we need to add a route for our controller. We will take in {resource}, which is used by Nova to automatically determine the resource of the request for a NovaRequest object, and an additional parameter of {field}, which is the current field we are trying to calculate. We use the field parameter to find and call the callback the user specified in their resource.

<?php

use Illuminate\Support\Facades\Route;

Route::post('/calculate/{resource}/{field}', 'CalculatedFieldController@calculate');

Controller

For this, we need to find which resource we are calling this from. This is done with the NovaRequest object. After that, find the fields which are available to the user, where the field attribute name matches our route parameter. Then, pass in a collection of whatever the current state is of the client side field into the user-defined callback. Finally, return it so that our client side component can make use of the value

<?php
namespace Codebykyle\CalculatedField\Http\Controllers;

use Illuminate\Routing\Controller;
use Laravel\Nova\Http\Requests\NovaRequest;


class CalculatedFieldController extends Controller
{
   public function calculate($resource, $field, NovaRequest $request)
   {
       $field = $request->newResource()
           ->availableFields($request)
           ->where('attribute', '=', $field)
           ->first();


       if (empty($field)) {
           abort(404, "Unable to find the field required to calculate this value");
       }

       $calculatedValue = call_user_func(
           $field->calculateFunction,
           collect($request->json()->all()),
           $request
       );

       return response()->json([
           'value' => $calculatedValue
       ]);
   }
}

Registering the route

Finally, we need to tell Laravel that this route is available. In our FieldServiceProvider.php, lets register this additional route by adding it in the register function of the service provider

<?php

namespace Codebykyle\CalculatedField;

use Illuminate\Support\Facades\Route;
use Laravel\Nova\Nova;
use Laravel\Nova\Events\ServingNova;
use Illuminate\Support\ServiceProvider;

class FieldServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        Nova::serving(function (ServingNova $event) {
            Nova::script('calculated-field', __DIR__.'/../dist/js/field.js');
            Nova::style('calculated-field', __DIR__.'/../dist/css/field.css');
        });
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        if ($this->app->routesAreCached()) {
            return;
        }

        Route::middleware(['nova'])
            ->namespace('Codebykyle\CalculatedField\Http\Controllers')
            ->prefix('codebykyle/calculated-field')
            ->group(__DIR__.'/../routes/api.php');
    }
}

Conclusion

Now we have a calculating field. You can use a string, or a number, and make a callback on the server to do whatever kind of math you need.

If you are interested in my version of this code, it is again available on my github:

Github

Or can be installed via composer:

composer require codebykyle/calculated-field

Please let me know if you have any questions or comments. You can reach me via twitter @CodeByKyle

Happy coding!

About Me

Kyle Shovan

I am a developer who is currently living in Ho Chi Minh City, Vietnam. I work with Laravel, VueJS, React, and other technologies in order to provide software to companies. Formerly a Microsoft Dynamics consultant.

Comments