FilamentPHP & Driver.js Integration

These days, I'm trying to integrate Driver.js into a FilamentPHP panel for a project. Making the adjustments I wanted took a bit of time. Therefore, I want to document this process with this blog post.

There's already a package available (filament-tour), but I preferred to try a solution that felt more clean to me. Integration became somewhat troublesome because I used the spa mode in my related panel. I'll share the methods I tried and the final working version. Note that there may be parts in the process section that are not present in the final code; these are things I figured out through trial and error and added, the most current version is the following final code section.

Final Code

AppServiceProvider.php

    public function boot(): void
    {
		// ...

        foreach ($this->tours() as $scope => $view) {
            FilamentView::registerRenderHook(
                PanelsRenderHook::PAGE_FOOTER_WIDGETS_AFTER,
                fn () => view($view, ['scopes' => $scope]),
                $scope
            );
        }

        FilamentAsset::register([
            Js::make('driver', __DIR__.'/../../resources/js/driver.js'),
            Css::make('driver', __DIR__.'/../../resources/css/driver.css'),
        ]);

		// ...
    }

	protected function tours(): array
    {
        return [
            ListProducts::class => 'filament.<yourpanel>.tours.list-products-tour',
        ];
    }

If spa mode is not enabled, add loadedOnRequest() method like below.

		// ...

        FilamentAsset::register([
            Js::make('driver', __DIR__.'/../../resources/js/driver.js')->loadedOnRequest(),
            Css::make('driver', __DIR__.'/../../resources/css/driver.css')->loadedOnRequest(),
        ]);

		// ...

list-products-tour.blade.php (SPA Mode Enabled)

@if($this->howto === 'add-new-product')
	@push('scripts')
    <script>
        (function () {
            let navigatedHandler = () => {
                const driverObj = driver({
                    showProgress: true,
                    steps: [
                        { element: '.page-header', popover: { title: 'Title', description: 'Description' } },
                        { element: '.top-nav', popover: { title: 'Title', description: 'Description' } },
                        { element: '.sidebar', popover: { title: 'Title', description: 'Description' } },
                        { element: '.footer', popover: { title: 'Title', description: 'Description' } },
                    ]
                });

                driverObj.drive()

                let navigatingHandler = () => {
                    driverObj.destroy()
                    document.removeEventListener('livewire:navigating', navigatedHandler)
                }

                document.addEventListener('livewire:navigating', navigatingHandler)
                document.removeEventListener('livewire:navigated', navigatedHandler)
            }
            document.addEventListener('livewire:navigated', navigatedHandler)
        })();
    </script>
	@endpush
@endif

list-products-tour.blade.php (SPA Mode Disabled)

@if($this->howto === 'add-new-product')
    <div
        x-data="{}"
        x-load-css="[@js(\Filament\Support\Facades\FilamentAsset::getStyleHref('driver'))]"
        x-load-js="[@js(\Filament\Support\Facades\FilamentAsset::getScriptSrc('driver'))]"
        class="hidden"
    ></div>
    <script>
        window.addEventListener('load', () => {
            const driverObj = driver({
                showProgress: true,
                steps: [
                    {element: '.page-header', popover: {title: 'Title', description: 'Description'}},
                    {element: '.top-nav', popover: {title: 'Title', description: 'Description'}},
                    {element: '.sidebar', popover: {title: 'Title', description: 'Description'}},
                    {element: '.footer', popover: {title: 'Title', description: 'Description'}},
                ]
            });

            driverObj.drive()
        });
    </script>
@endif

ListProducts.php (Could be any FilamentPHP page)

	// ...

    #[Url]
    public string $howto = '';

	// ...

Process

At first, I followed the installation instructions in the driver.js documentation.

# Using npm
npm install driver.js

# Using pnpm
pnpm install driver.js

# Using yarn
yarn add driver.js

After that, I created a driver.js file in the resources/js folder with the content below. I also added this file to the input section of my vite.config.js file.

import { driver } from "driver.js";
import "driver.js/dist/driver.css";

window.driver = driver;

Next, since I might want to show the tours anywhere (pages, resources, dashboards etc.), I decided to use render hooks. At first, I tried adding tours by overriding the views of the components where I wanted to add the tour, and it worked quite well and could be valid for some situations, and I might continue to use it that way. But, as I said, since I wanted to integrate tours into any page, overriding each component's view seemed tedious.

We add render hooks inside the boot method of AppServiceProvider. For now, the code I'm using is as follows:

foreach ($this->tours() as $scope => $view) {
   FilamentView::registerRenderHook(
       PanelsRenderHook::PAGE_FOOTER_WIDGETS_AFTER,
       fn () => view($view, ['scopes' => $scope]),
       $scope
   );
}
protected function tours(): array
{
   return [
       ListProducts::class => 'filament.yourpanel.tours.list-products-tour',
   ];
}

This way, we now have a view file scoped to the class we want, and we need to add our driver.js assets to this file. For this, I tested Filament's Lazy Loading Javascript section. However, as I'll see later, this causes some problems in the structure I've set up if the spa mode enabled. So, I added the driver.js file in my created view file as following:

@push('scripts')
   @vite(['resources/js/driver.js'])
@endpush

The part where I spent the most time was determining how to listen for the correct time driver function is defined. The two event listeners below caused various problems in a situation where the spa mode is active. At first, the tour wouldn't start without applying a full refresh to the page. Luckily, when I examined Livewire's navigate documentation, I found a solution.

document.addEventListener('DOMContentLoaded', () => {});
window.addEventListener('load', () => {});

Since the page is never refreshed, the relevant JavaScript code only runs once. However, when I started listening to Livewire navigate event (livewire:navigated), the code started working on pages loaded with wire:navigate.

document.addEventListener('livewire:navigated', () => {});

This was the first step because, as I realized a bit late, and as it's written in the documentation, the listener code added in this way continues to work on the following loaded pages.

To make it a bit more concrete, let's take an example driver.js code.

@push('scripts')
   @vite(['resources/js/driver.js'])
   <script>
       const driverObj = driver({
           showProgress: true,
           steps: [
               { element: '.page-header', popover: { title: 'Title', description: 'Description' } },
               { element: '.top-nav', popover: { title: 'Title', description: 'Description' } },
               { element: '.sidebar', popover: { title: 'Title', description: 'Description' } },
               { element: '.footer', popover: { title: 'Title', description: 'Description' } },
           ]
       });

       driverObj.drive();
   </script>
@endpush

With the updates I mentioned, this code became as follows:

@push('scripts')
   @vite(['resources/js/driver.js'])
   <script>
       (function () {
           let driverObj;

           function driverListener() {
               driverObj = driver({...}); // add options and steps here

               driverObj.drive();
           }

			document.addEventListener('livewire:navigated', driverListener);

			document.addEventListener('livewire:navigating', () => {

			document.removeEventListener('livewire:navigated', driverListener);
               driverObj.destroy();
           }, {once: true});
       })();
   </script>
@endpush

When we navigate to another component with wire:navigate, since we are on the same page, we remove the first event listener we created before navigating to another page with the livewire:navigating event. At the same time, we also destroy the driver object we created. We added {once: true} because we want the navigating event to work only once. Thus, even if we return to the same page using the back button, our component will work as if we have loaded it for the first time. Also, since we won't be able to define the same variables when we return to the first page, we enclosed the code in an anonymous function to create a scope. Thus, a tour component that works and can be integrated into any page has been created. Of course, it's possible to refactor repeating parts. I will continue to work on it as needed and as new tours are added.

I decided to run the tours in my project using the query parameters. So I made a few more additions. I added a howto property to the livewire components where I want to add the tours as follows. The relevant part in Livewire documentation: URL Query Parameters

#[Url]
public string $howto = '';

I adjusted it so that tours would be triggered when navigated to the relevant tour URL with the query parameters, as I didn't want them to run every time the related page is loaded.

@if($this->howto === 'add-new-product')
   @push('scripts')
       @vite(['resources/js/driver.js'])
       <script>
           (function () {
               ...
           })();
       </script>
   @endpush
@endif

At this point, it became possible to initiate tours using a link such as filament-app.com/products?howto=add-new-product for a products resource.

In my subsequent tests, I continued to experience problems in SPA mode. There was a problem with the loading of assets on pages that were part of a tour for the first time. I decided to stop trying to load them with Vite and used Filament Asset Management even if it meant loading these assets on every page. The {once: true} part I used in the navigating event didn't work exactly as I thought. It worked once in total, not once per component. In this case, I preferred to remove the events I directly created in the final code.

That's all for now, I will update this post if I make any updates. If you know a better or more elegant solution for this purpose, please do not hesitate to get in touch! :)