Notifications & Actions in Primix Visual Builder Pages
This document covers how Primix surfaces notifications to the user, how builder-canvas events flow between the Vue iframe and PHP/Livewire, and how to wire up header actions on visual builder pages.
The notification bridge
InteractsWithVisualBuilderPrimix (the Primix trait used by visual builder pages) implements two notification helpers:
use Primix\Notifications\Notification; // ← correct namespace
protected function notifySuccess(string $message): void
{
Notification::make()
->title($message)
->success()
->send();
}
protected function notifyError(string $message): void
{
Notification::make()
->title($message)
->danger()
->send();
}
Warning: do not import
Filament\Notifications\Notification. Primix ships its own notification class. Mixing the two namespaces causes a silent render failure.
Save success: the saveFromVue() flow
When the user clicks Save inside the builder canvas, the canvas emits a tagixo:save window event. The Primix bridge script intercepts it and calls livue.call('saveFromVue'), which reaches the PHP method of the same name on the page component:
// Inside your PrimixVisualBuilderPage subclass
public function saveFromVue(array $payload): void
{
try {
$this->saveStructure($payload);
$this->notifySuccess('Page saved.');
$this->dispatch('tagixo:saved'); // tells the canvas save succeeded
} catch (\Exception $e) {
$this->notifyError('Save failed: ' . $e->getMessage());
$this->dispatch('tagixo:save-error'); // tells the canvas save failed
// do NOT re-throw — the catch block is the terminal handler here
}
}
protected function saveStructure(array $payload): void
{
$page = $this->getRecord();
$page->structure = $payload['structure'] ?? [];
$page->save();
// Re-render so the preview reflects the new content
PageRenderer::clearCache($page);
}
The exception is routed to tagixo:save-error rather than propagated — this prevents a 500 page from replacing the builder UI mid-session.
LiVue dispatch vs Livewire
Primix visual builder pages use LiVue dispatch for canvas↔PHP communication, not raw Livewire events. The two mechanisms differ:
| Concern | LiVue (livue.call / livue.on) |
Livewire (wire:click / #[On]) |
|---|---|---|
| Crosses iframe boundary | Yes — LiVue bridge handles postMessage | No — Livewire expects same-document DOM |
| PHP listener attribute | #[On('livue:methodName')] (auto-routed) |
#[On('eventName')] |
| JS emit | window.livue.call('methodName', payload) |
$wire.call('methodName', payload) |
| JS listen | window.livue.on('eventName', cb) |
$wire.on('eventName', cb) |
PHP side — listening to a LiVue call:
// Method name IS the event — no attribute needed for livue.call()
public function saveFromVue(array $payload): void { ... }
// For fire-and-forget events emitted by the canvas:
#[On('tagixo:requestPreview')]
public function handlePreviewRequest(): void { ... }
JS side — calling PHP from inside (or outside) the canvas:
// Call a PHP method with a payload
window.livue.call('saveFromVue', { structure: [...] });
// Listen to a PHP-dispatched event
window.livue.on('tagixo:saved', () => console.log('saved'));
Builder canvas events
Events emitted by the canvas (JS → PHP)
| Event | Trigger | Payload |
|---|---|---|
tagixo:save |
User clicks Save in builder | { structure, meta } |
tagixo:requestPreview |
User clicks Preview in builder | — |
tagixo:dirty |
Unsaved changes detected | { dirty: bool } |
Events listened to by the canvas (PHP → JS)
| Event | Effect |
|---|---|
tagixo:saved |
Clears dirty flag, shows in-canvas "Saved" toast |
tagixo:save-error |
Re-enables Save button, shows in-canvas error |
tagixo:reload |
Full canvas reload (use after publish/unpublish) |
The bridge script inside builder-vue.blade.php wires these together:
// tagixo:save → livue.call('saveFromVue') → tagixo:saved / tagixo:save-error
window.addEventListener('tagixo:save', (e) => {
window.livue.call('saveFromVue', e.detail)
.then(() => window.dispatchEvent(new Event('tagixo:saved')))
.catch(() => window.dispatchEvent(new Event('tagixo:save-error')));
});
Attaching your own JS listener for canvas events:
window.addEventListener('tagixo:saved', () => {
// e.g. update a "Last saved" timestamp in the page chrome
});
Header actions on builder pages
Override getHeaderActions() on your page class to add Primix Action instances to the top bar:
use Primix\Pages\Actions\Action;
protected function getHeaderActions(): array
{
return [
Action::make('preview')
->label('Preview')
->icon('heroicon-o-eye')
->url(fn () => route('pages.show', $this->getRecord()->slug))
->openUrlInNewTab(),
Action::make('publish')
->label('Publish')
->icon('heroicon-o-globe-alt')
->color('success')
->action(function () {
$this->getRecord()->update(['published_at' => now()]);
$this->notifySuccess('Page published.');
}),
];
}
Common Action methods:
| Method | Purpose |
|---|---|
->label(string) |
Button label |
->icon(string) |
Heroicon name |
->color(string) |
primary / success / danger / warning / gray |
->action(Closure) |
PHP callback executed on click |
->url(string|Closure) |
Navigate instead of calling PHP |
->openUrlInNewTab() |
Opens ->url() in a new browser tab |
->visible(Closure) |
Conditionally show/hide the action |
->disabled(Closure) |
Render but disable the button |
->requiresConfirmation() |
Show a modal before executing |
Confirmation before destructive actions
Use ->requiresConfirmation() to gate dangerous actions behind a modal:
Action::make('resetContent')
->label('Reset content')
->icon('heroicon-o-arrow-path')
->color('danger')
->requiresConfirmation()
->modalHeading('Reset page content?')
->modalDescription('This will discard all builder content and cannot be undone.')
->modalSubmitActionLabel('Yes, reset')
->action(function () {
$this->getRecord()->update(['structure' => []]);
$this->dispatch('tagixo:reload'); // force canvas to reload blank state
$this->notifySuccess('Content reset.');
}),
The modal is rendered by Primix's own dialog component — no additional Blade markup required.
Unsaved changes in LiVue context
The builder canvas registers its own beforeunload guard whenever dirty === true. This covers hard browser navigations (closing tab, typing a new URL).
For in-app SPA navigations (clicking a sidebar link), Primix exposes a beforeLeave() hook on the page component:
public function beforeLeave(): bool
{
// Return false to block navigation and show an "Unsaved changes" prompt.
// The canvas emits 'tagixo:dirty' which you can track in a component property.
return ! $this->isDirty;
}
Track dirty state by listening to the canvas event:
public bool $isDirty = false;
#[On('tagixo:dirty')]
public function setDirty(bool $dirty): void
{
$this->isDirty = $dirty;
}
Complete example
A full PrimixVisualBuilderPage with Preview, Publish, and Unpublish header actions:
<?php
namespace App\Primix\Pages;
use App\Models\Page;
use Ccast\Tagixo\Services\PageRenderer;
use Primix\Pages\Actions\Action;
use Primix\Pages\PrimixVisualBuilderPage;
class EditPage extends PrimixVisualBuilderPage
{
protected static string $model = Page::class;
public bool $isDirty = false;
// ── Canvas events ────────────────────────────────────────────────────────
public function saveFromVue(array $payload): void
{
try {
$page = $this->getRecord();
$page->structure = $payload['structure'] ?? [];
$page->save();
PageRenderer::clearCache($page);
$this->notifySuccess('Page saved.');
$this->dispatch('tagixo:saved');
} catch (\Exception $e) {
$this->notifyError('Save failed: ' . $e->getMessage());
$this->dispatch('tagixo:save-error');
}
}
#[On('tagixo:dirty')]
public function setDirty(bool $dirty): void
{
$this->isDirty = $dirty;
}
// ── Navigation guard ─────────────────────────────────────────────────────
public function beforeLeave(): bool
{
return ! $this->isDirty;
}
// ── Header actions ───────────────────────────────────────────────────────
protected function getHeaderActions(): array
{
return [
Action::make('preview')
->label('Preview')
->icon('heroicon-o-eye')
->url(fn () => route('pages.show', $this->getRecord()->slug))
->openUrlInNewTab(),
Action::make('unpublish')
->label('Unpublish')
->icon('heroicon-o-eye-slash')
->color('warning')
->visible(fn () => $this->getRecord()->isPublished())
->requiresConfirmation()
->modalHeading('Unpublish this page?')
->modalDescription('The page will be immediately removed from the public site.')
->modalSubmitActionLabel('Unpublish')
->action(function () {
$this->getRecord()->update(['published_at' => null]);
$this->notifySuccess('Page unpublished.');
}),
Action::make('publish')
->label('Publish')
->icon('heroicon-o-globe-alt')
->color('success')
->visible(fn () => ! $this->getRecord()->isPublished())
->action(function () {
$this->getRecord()->update(['published_at' => now()]);
$this->notifySuccess('Page published.');
}),
];
}
}