FilamentPHP ile Driver.js Entegrasyonu

Original – Dev

Bugünlerde bir projem için Driver.js’i bir FilamentPHP paneline entegre etmeye çalışıyorum. Tam olarak istediğim ayarlamaları yapmak biraz zaman aldı. Bu yüzden de bu süreci bu blog yazısıyla kayda geçirmek istiyorum.

Bu iş için hâlihazırda bir paket mevcut (filament-tour) fakat ben daha çok içime sinen bir yol izlemeyi tercih ettim. İlgili panelimde spa modunu kullandığım için entegrasyon biraz uğraştırıcı oldu. Denediğim yolları ve son çalışan hâlini paylaşacağım. Süreç kısmında son kodda bulunmayan şeyler olabilir, deneme yanılma ile çıkardığım ve eklediğim şeyler mevcut en günceli aşağıdaki son kod kısmıdır.

Son Kod

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',
        ];
    }

SPA modu açık değilse aşağıdaki gibi loadedOnRequest() methodunu ekleyin.

		// ...
		
        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 Modu Aktif)

@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 Modu Kapalı)

@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 (Herhangi bir filament sayfası olabilir)

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

Süreç

İlk aşamada aşağıdaki gibi driver.js dokümantasyonunda olduğu gibi kurulumu yaptım.

# Using npm
npm install driver.js  

# Using pnpm
pnpm install driver.js 

# Using yarn
yarn add driver.js

Daha sonra resources/js klasöründe aşağıdaki içerikle driver.js dosyasını oluşturdum. Bu dosyayı aynı zamanda vite.config.js dosyamın input bölümüne de ekledim.

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

window.driver = driver;

Daha sonra oluşturacağım turları herhangi bir yerde göstermek isteyebileceğimden render hookları kullanmaya karar verdim. İlk başta tur eklemek istediğim componentlerin viewlerini override ederek tur eklemeyi denedim, gayet güzel de çalıştı ve bazı durumlar için geçerli olabilir ve o şekilde kullanmaya devam edebilirim. Fakat dediğim gibi turları herhangi bir sayfaya entegre etmek istediğimden her componentin viewini override etmek yorucu geldi.

Render hookları AppServiceProvider’ın boot methodunun içine ekliyoruz. Daha iyisine ihtiyaç doğana kadar şu an için kullandığım kod şu:

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',
   ];
}

Bu sayede artık sadece istediğimiz class’a bağlanmış bir view dosyamız var ve bu dosyaya oluşturduğumuz driver.js asset’ini eklemeliyiz. Bunun için Filament’in Lazy Loading Javascript bölümünü inceledim ve test ettim. Fakat daha sonra da göreceğim üzere bu şu anki kurduğum yapıda bazı sıkıntılar çıkartıyor. Bu yüzden oluşturduğum view dosyasında driver.js dosyasını şu şekilde ekledim:

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

Bu kısımda en çok vakit harcadığım yerlerden biri import ettiğimiz driver fonksiyonunu ve driverObj’i çalıştırmak oldu. Aşağıdaki iki event listener da spa modunun aktif olduğu bir durumda çeşitli sorunlara yol açtı. Bu en başta sayfaya full refresh uygulamadan turun başlamamasıydı. Neyse ki Livewire navigate dokümantasyonunu incelediğimde çözüme ulaştım.

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

Sayfa hiç yenilenmediğinden ilgili javascript kodu yalnızca bir defa çalışıyordu. Fakat aşağıdaki gibi Livewire navigate eventini (livewire:navigated) dinlemeye başladımda ise wire:navigate ile yüklenen sayfalarda da kod çalışmaya başladı.

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

Bu ilk aşamaydı çünkü biraz geç de olsa daha sonradan fark ettiğim ve dokümantasyonda yazdığı üzere bu şekilde eklenen listener kodu yüklenen sonraki sayfalarda da çalışmaya devam ediyor.

Biraz daha somutlaştırmak adına örnek bir driver.js kodunu ele alalım.

@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

Söylediğim güncellemelerle birlikte bu kod şu hâle geldi:

@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

Başka bir component’e wire:navigate ile gittiğimizden aynı sayfada olduğumuz için livewire:navigating eventi ile bir başka sayfaya gitmeden önce oluşturduğumuz ilk event listener’ı kaldırıyoruz. Aynı zamanda da oluşturduğumuz driver objesini de yok ediyoruz. Navigating eventinin yalnızca 1 kere çalışmasını istediğimizden {once: true} kısmını ekledik. Böylece geri tuşunu kullanarak aynı sayfaya dönsek bile componentimiz ilk defa girmişiz gibi çalışacak. Aynı zamanda ilk sayfaya geri döndüğümüzde aynı variable’ları tanımlayamayacağımızdan kodu anonim bir fonksiyonun içine alarak bir scope oluşturduk. Böylece artık çalışan ve herhangi bir sayfaya entegre edilebilecek bir tur componenti oluşmuş oldu. Tabii tekrar eden yerleri de düzenlemek mümkün. İhtiyaç oldukça ve yeni turlar eklendikçe üzerinde çalışmaya devam edeceğim.

Turları projemde query parameterlerine bağlı olarak çalıştırmaya karar verdim. Bu durumda yaptığım birkaç ekleme daha oldu. Tur eklemek istediğim livewire componentlerine aşağıdaki gibi bir howto propertysi ekledim. Livewire dokümantasyonunda ilgili kısım: URL Query Parameters

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

Turların ilgili sayfada her yüklendiğinde çalışmasını istemediğimden bu şekilde ilgili tur url’i ile girildiğinde çalışacak şekilde ayarladım.

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

Bu hâliyle örneğin bir products resource’u için filament-app.com/products?howto=add-new-product gibi bir bağlantıyı kullanarak turları başlatmak mümkün oldu.

Sonraki teslerimde spa modunda sıkıntılar yaşamaya devam ettim. İlk defa girilen tur olan sayfalarda assetlerin yüklenmesiyle ilgili problem oldu. Vite ile yüklemekten vazgeçip her sayfada yüklenecek de olsa Filament Asset Management kullandım. Navigating eventinde kullandığım {once: true} kısmı da tam olarak düşündüğüm gibi çalışmıyormuş. Her component için bir kere değil, toplamda bir kere çalışıyormuş. Bu durumda direkt oluşturduğum eventleri kaldırmayı tercih ettim.

Şimdilik bu kadar, herhangi bir güncelleme yaptığımda yine bu yazıya ekleyeceğim. Bu amaç için daha iyi ya da daha şık bir yöntem biliyorsanız lütfen iletişime geçmekten çekinmeyiniz! :)