add event dispatching system for Livewire integration

Implement consistent event dispatching across Noty, Notyf, Toastr adapters and
themes, following the existing SweetAlert pattern. This enables Livewire
integration for all notification types.

JavaScript Events:
- Noty: flasher:noty:show, flasher:noty:click, flasher:noty:close, flasher:noty:hover
- Notyf: flasher:notyf:click, flasher:notyf:dismiss
- Toastr: flasher:toastr:show, flasher:toastr:click, flasher:toastr:close, flasher:toastr:hidden
- Themes: flasher:theme:click (generic), flasher:theme:{name}:click (specific)

PHP Livewire Listeners:
- LivewireListener for each adapter (Noty, Notyf, Toastr)
- ThemeLivewireListener for theme click events
- Registered in service providers when Livewire is bound

This allows Livewire users to listen for notification events and react
accordingly (e.g., noty:click, theme:flasher:click).
This commit is contained in:
Younes ENNAJI
2026-03-01 21:05:10 +00:00
parent f1051e1d7f
commit 7d6e9b46b8
42 changed files with 1342 additions and 15 deletions
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Laravel\EventListener;
use Flasher\Laravel\EventListener\ThemeLivewireListener;
use Flasher\Prime\EventDispatcher\Event\ResponseEvent;
use PHPUnit\Framework\TestCase;
final class ThemeLivewireListenerTest extends TestCase
{
private ThemeLivewireListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new ThemeLivewireListener();
}
public function testGetSubscribedEvents(): void
{
$this->assertSame(ResponseEvent::class, $this->listener->getSubscribedEvents());
}
public function testInvokeSkipsNonHtmlPresenter(): void
{
$event = new ResponseEvent('', 'json');
($this->listener)($event);
$this->assertSame('', $event->getResponse());
}
public function testInvokeSkipsResponseWithoutFlasherScript(): void
{
$event = new ResponseEvent('<html><body>No flasher</body></html>', 'html');
($this->listener)($event);
$this->assertSame('<html><body>No flasher</body></html>', $event->getResponse());
}
public function testInvokeSkipsDuplicateInjection(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$response .= '<script type="text/javascript" class="flasher-theme-livewire-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertSame($response, $event->getResponse());
}
public function testInvokeInjectsLivewireScript(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertStringContainsString('flasher-theme-livewire-js', $event->getResponse());
$this->assertStringContainsString('flasher:theme:click', $event->getResponse());
$this->assertStringContainsString('theme:click', $event->getResponse());
$this->assertStringContainsString("theme:' + themeName + ':click", $event->getResponse());
}
}
@@ -53,6 +53,23 @@ final class FlasherNotyServiceProviderTest extends TestCase
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend');
$this->app->expects('bound')->with('livewire')->andReturn(false);
$this->serviceProvider->register();
$this->serviceProvider->boot();
$this->addToAssertionCount(1);
}
public function testBootWithLivewire(): void
{
$this->app->expects()->make('config')->andReturns($configMock = \Mockery::mock(Repository::class));
$configMock->expects('get')->andReturns([]);
$configMock->expects('set');
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend')->twice();
$this->app->expects('bound')->with('livewire')->andReturn(true);
$this->serviceProvider->register();
$this->serviceProvider->boot();
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Noty\Laravel;
use Flasher\Noty\Laravel\LivewireListener;
use Flasher\Prime\EventDispatcher\Event\ResponseEvent;
use PHPUnit\Framework\TestCase;
final class LivewireListenerTest extends TestCase
{
private LivewireListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new LivewireListener();
}
public function testGetSubscribedEvents(): void
{
$this->assertSame(ResponseEvent::class, $this->listener->getSubscribedEvents());
}
public function testInvokeSkipsNonHtmlPresenter(): void
{
$event = new ResponseEvent('', 'json');
($this->listener)($event);
$this->assertSame('', $event->getResponse());
}
public function testInvokeSkipsResponseWithoutFlasherScript(): void
{
$event = new ResponseEvent('<html><body>No flasher</body></html>', 'html');
($this->listener)($event);
$this->assertSame('<html><body>No flasher</body></html>', $event->getResponse());
}
public function testInvokeSkipsDuplicateInjection(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$response .= '<script type="text/javascript" class="flasher-noty-livewire-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertSame($response, $event->getResponse());
}
public function testInvokeInjectsLivewireScript(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertStringContainsString('flasher-noty-livewire-js', $event->getResponse());
$this->assertStringContainsString('flasher:noty:show', $event->getResponse());
$this->assertStringContainsString('flasher:noty:click', $event->getResponse());
$this->assertStringContainsString('flasher:noty:close', $event->getResponse());
$this->assertStringContainsString('flasher:noty:hover', $event->getResponse());
}
}
@@ -53,6 +53,23 @@ final class FlasherNotyfServiceProviderTest extends TestCase
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend');
$this->app->expects('bound')->with('livewire')->andReturn(false);
$this->serviceProvider->register();
$this->serviceProvider->boot();
$this->addToAssertionCount(1);
}
public function testBootWithLivewire(): void
{
$this->app->expects()->make('config')->andReturns($configMock = \Mockery::mock(Repository::class));
$configMock->expects('get')->andReturns([]);
$configMock->expects('set');
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend')->twice();
$this->app->expects('bound')->with('livewire')->andReturn(true);
$this->serviceProvider->register();
$this->serviceProvider->boot();
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Notyf\Laravel;
use Flasher\Notyf\Laravel\LivewireListener;
use Flasher\Prime\EventDispatcher\Event\ResponseEvent;
use PHPUnit\Framework\TestCase;
final class LivewireListenerTest extends TestCase
{
private LivewireListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new LivewireListener();
}
public function testGetSubscribedEvents(): void
{
$this->assertSame(ResponseEvent::class, $this->listener->getSubscribedEvents());
}
public function testInvokeSkipsNonHtmlPresenter(): void
{
$event = new ResponseEvent('', 'json');
($this->listener)($event);
$this->assertSame('', $event->getResponse());
}
public function testInvokeSkipsResponseWithoutFlasherScript(): void
{
$event = new ResponseEvent('<html><body>No flasher</body></html>', 'html');
($this->listener)($event);
$this->assertSame('<html><body>No flasher</body></html>', $event->getResponse());
}
public function testInvokeSkipsDuplicateInjection(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$response .= '<script type="text/javascript" class="flasher-notyf-livewire-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertSame($response, $event->getResponse());
}
public function testInvokeInjectsLivewireScript(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertStringContainsString('flasher-notyf-livewire-js', $event->getResponse());
$this->assertStringContainsString('flasher:notyf:click', $event->getResponse());
$this->assertStringContainsString('flasher:notyf:dismiss', $event->getResponse());
}
}
@@ -53,6 +53,23 @@ final class FlasherToastrServiceProviderTest extends TestCase
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend');
$this->app->expects('bound')->with('livewire')->andReturn(false);
$this->serviceProvider->register();
$this->serviceProvider->boot();
$this->addToAssertionCount(1);
}
public function testBootWithLivewire(): void
{
$this->app->expects()->make('config')->andReturns($configMock = \Mockery::mock(Repository::class));
$configMock->expects('get')->andReturns([]);
$configMock->expects('set');
$this->app->expects('singleton');
$this->app->expects('alias');
$this->app->expects('extend')->twice();
$this->app->expects('bound')->with('livewire')->andReturn(true);
$this->serviceProvider->register();
$this->serviceProvider->boot();
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Flasher\Tests\Toastr\Laravel;
use Flasher\Prime\EventDispatcher\Event\ResponseEvent;
use Flasher\Toastr\Laravel\LivewireListener;
use PHPUnit\Framework\TestCase;
final class LivewireListenerTest extends TestCase
{
private LivewireListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = new LivewireListener();
}
public function testGetSubscribedEvents(): void
{
$this->assertSame(ResponseEvent::class, $this->listener->getSubscribedEvents());
}
public function testInvokeSkipsNonHtmlPresenter(): void
{
$event = new ResponseEvent('', 'json');
($this->listener)($event);
$this->assertSame('', $event->getResponse());
}
public function testInvokeSkipsResponseWithoutFlasherScript(): void
{
$event = new ResponseEvent('<html><body>No flasher</body></html>', 'html');
($this->listener)($event);
$this->assertSame('<html><body>No flasher</body></html>', $event->getResponse());
}
public function testInvokeSkipsDuplicateInjection(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$response .= '<script type="text/javascript" class="flasher-toastr-livewire-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertSame($response, $event->getResponse());
}
public function testInvokeInjectsLivewireScript(): void
{
$response = '<script type="text/javascript" class="flasher-js"></script>';
$event = new ResponseEvent($response, 'html');
($this->listener)($event);
$this->assertStringContainsString('flasher-toastr-livewire-js', $event->getResponse());
$this->assertStringContainsString('flasher:toastr:show', $event->getResponse());
$this->assertStringContainsString('flasher:toastr:click', $event->getResponse());
$this->assertStringContainsString('flasher:toastr:close', $event->getResponse());
$this->assertStringContainsString('flasher:toastr:hidden', $event->getResponse());
}
}
+95
View File
@@ -224,4 +224,99 @@ describe('NotyPlugin', () => {
})
})
})
describe('event dispatching', () => {
it('should set up event callbacks that dispatch custom events', () => {
plugin.renderEnvelopes([createEnvelope()])
// Verify callbacks are set up in options
const calledOptions = MockNoty.mock.calls[0][0]
expect(calledOptions.callbacks).toBeDefined()
expect(calledOptions.callbacks.onShow).toBeInstanceOf(Function)
expect(calledOptions.callbacks.onClick).toBeInstanceOf(Function)
expect(calledOptions.callbacks.onClose).toBeInstanceOf(Function)
expect(calledOptions.callbacks.onHover).toBeInstanceOf(Function)
})
it('should dispatch flasher:noty:show event when onShow callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:noty:show', eventHandler)
plugin.renderEnvelopes([createEnvelope({ message: 'Test' })])
const calledOptions = MockNoty.mock.calls[0][0]
calledOptions.callbacks.onShow()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Test' }),
}),
}))
window.removeEventListener('flasher:noty:show', eventHandler)
})
it('should dispatch flasher:noty:click event when onClick callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:noty:click', eventHandler)
plugin.renderEnvelopes([createEnvelope({ message: 'Click test' })])
const calledOptions = MockNoty.mock.calls[0][0]
calledOptions.callbacks.onClick()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Click test' }),
}),
}))
window.removeEventListener('flasher:noty:click', eventHandler)
})
it('should dispatch flasher:noty:close event when onClose callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:noty:close', eventHandler)
plugin.renderEnvelopes([createEnvelope()])
const calledOptions = MockNoty.mock.calls[0][0]
calledOptions.callbacks.onClose()
expect(eventHandler).toHaveBeenCalled()
window.removeEventListener('flasher:noty:close', eventHandler)
})
it('should dispatch flasher:noty:hover event when onHover callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:noty:hover', eventHandler)
plugin.renderEnvelopes([createEnvelope()])
const calledOptions = MockNoty.mock.calls[0][0]
calledOptions.callbacks.onHover()
expect(eventHandler).toHaveBeenCalled()
window.removeEventListener('flasher:noty:hover', eventHandler)
})
it('should call original callbacks if provided', () => {
const originalOnClick = vi.fn()
plugin.renderEnvelopes([createEnvelope({
options: {
callbacks: {
onClick: originalOnClick,
},
},
})])
const calledOptions = MockNoty.mock.calls[0][0]
calledOptions.callbacks.onClick()
expect(originalOnClick).toHaveBeenCalled()
})
})
})
+91 -1
View File
@@ -81,7 +81,7 @@ describe('ToastrPlugin', () => {
expect(mockToastr.success).toHaveBeenCalledWith(
'Hello World',
'Greeting',
{ timeOut: 5000 },
expect.objectContaining({ timeOut: 5000 }),
)
})
@@ -218,4 +218,94 @@ describe('ToastrPlugin', () => {
expect(mockToastr.warning).toHaveBeenCalled()
})
})
describe('event dispatching', () => {
it('should set up event callbacks that dispatch custom events', () => {
plugin.renderEnvelopes([createEnvelope()])
// Verify options passed to toastr include event callbacks
const calledOptions = mockToastr.success.mock.calls[0][2]
expect(calledOptions.onShown).toBeInstanceOf(Function)
expect(calledOptions.onclick).toBeInstanceOf(Function)
expect(calledOptions.onCloseClick).toBeInstanceOf(Function)
expect(calledOptions.onHidden).toBeInstanceOf(Function)
})
it('should dispatch flasher:toastr:show event when onShown callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:toastr:show', eventHandler)
plugin.renderEnvelopes([createEnvelope({ message: 'Show test' })])
const calledOptions = mockToastr.success.mock.calls[0][2]
calledOptions.onShown()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Show test' }),
}),
}))
window.removeEventListener('flasher:toastr:show', eventHandler)
})
it('should dispatch flasher:toastr:click event when onclick callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:toastr:click', eventHandler)
plugin.renderEnvelopes([createEnvelope({ message: 'Click test' })])
const calledOptions = mockToastr.success.mock.calls[0][2]
calledOptions.onclick()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Click test' }),
}),
}))
window.removeEventListener('flasher:toastr:click', eventHandler)
})
it('should dispatch flasher:toastr:close event when onCloseClick callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:toastr:close', eventHandler)
plugin.renderEnvelopes([createEnvelope()])
const calledOptions = mockToastr.success.mock.calls[0][2]
calledOptions.onCloseClick()
expect(eventHandler).toHaveBeenCalled()
window.removeEventListener('flasher:toastr:close', eventHandler)
})
it('should dispatch flasher:toastr:hidden event when onHidden callback is called', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:toastr:hidden', eventHandler)
plugin.renderEnvelopes([createEnvelope()])
const calledOptions = mockToastr.success.mock.calls[0][2]
calledOptions.onHidden()
expect(eventHandler).toHaveBeenCalled()
window.removeEventListener('flasher:toastr:hidden', eventHandler)
})
it('should call original callbacks if provided', () => {
const originalOnClick = vi.fn()
plugin.renderEnvelopes([createEnvelope({
options: { onclick: originalOnClick },
})])
const calledOptions = mockToastr.success.mock.calls[0][2]
calledOptions.onclick()
expect(originalOnClick).toHaveBeenCalled()
})
})
})
+95
View File
@@ -675,4 +675,99 @@ describe('FlasherPlugin', () => {
})
})
})
describe('click event dispatching', () => {
it('should dispatch flasher:theme:click event when notification is clicked', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:theme:click', eventHandler)
plugin.renderEnvelopes([createEnvelope({ message: 'Click me' })])
const notification = document.querySelector('.fl-notification') as HTMLElement
expect(notification).toBeTruthy()
notification.click()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Click me' }),
}),
}))
window.removeEventListener('flasher:theme:click', eventHandler)
})
it('should dispatch theme-specific click event (flasher:theme:{name}:click)', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:theme:test:click', eventHandler)
plugin.renderEnvelopes([createEnvelope({
message: 'Theme specific',
metadata: { plugin: 'theme.test' },
})])
const notification = document.querySelector('.fl-notification') as HTMLElement
notification.click()
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
envelope: expect.objectContaining({ message: 'Theme specific' }),
}),
}))
window.removeEventListener('flasher:theme:test:click', eventHandler)
})
it('should dispatch both generic and specific events on click', () => {
const genericHandler = vi.fn()
const specificHandler = vi.fn()
window.addEventListener('flasher:theme:click', genericHandler)
window.addEventListener('flasher:theme:test:click', specificHandler)
plugin.renderEnvelopes([createEnvelope({
metadata: { plugin: 'theme.test' },
})])
const notification = document.querySelector('.fl-notification') as HTMLElement
notification.click()
expect(genericHandler).toHaveBeenCalled()
expect(specificHandler).toHaveBeenCalled()
window.removeEventListener('flasher:theme:click', genericHandler)
window.removeEventListener('flasher:theme:test:click', specificHandler)
})
it('should not dispatch click event when close button is clicked', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:theme:click', eventHandler)
plugin.renderEnvelopes([createEnvelope()])
const closeButton = document.querySelector('.fl-close') as HTMLElement
expect(closeButton).toBeTruthy()
closeButton.click()
expect(eventHandler).not.toHaveBeenCalled()
window.removeEventListener('flasher:theme:click', eventHandler)
})
it('should handle flasher plugin alias correctly', () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:theme:flasher:click', eventHandler)
plugin.renderEnvelopes([createEnvelope({
metadata: { plugin: 'flasher' },
})])
const notification = document.querySelector('.fl-notification') as HTMLElement
notification.click()
expect(eventHandler).toHaveBeenCalled()
window.removeEventListener('flasher:theme:flasher:click', eventHandler)
})
})
})