diff --git a/app/Livewire/Notifications.php b/app/Livewire/Notifications.php new file mode 100644 index 000000000000..76c89451626c --- /dev/null +++ b/app/Livewire/Notifications.php @@ -0,0 +1,217 @@ +Assigned to Jane Doe', + * icon: 'fas fa-laptop', + * html: true, + * tag: 'asset-create' + * }); + * + * Update same tagged notification later: + * Livewire.dispatch('showNotification', { + * type: 'info', + * message: 'Processing (70%)', + * tag: 'bulk-import' + * }); + * + * Dismiss all notifcations: + * Livewire.dispatch('dismissAllNotifications'); + * + */ +class Notifications extends Component +{ + /** + * Each alert structure: + * [ + * 'id' => string, + * 'type' => 'success'|'danger'|'warning'|'info', + * 'tag' => string|null, + * 'title' => string|null, + * 'message' => string, + * 'description' => string|null, + * 'icon' => string|null, + * 'html' => bool, + * 'created_at' => int (timestamp) + * ] + * + * @var array> + */ + + public array $liveAlerts=[]; + + /** + * Main notification listener. + * We bind both 'showNotification' (your current event) and 'notify' (optional alias). + */ + + #[On('showNotification')] + #[On('notify')] + public function notify( + $type=null, + $message=null, + $title=null, + $description=null, + $icon=null, + $html=null, + $tag=null, + $payload=null // wrapper form: { payload: { ... } } + ): void { + // Wrapper form: { payload: { ...full data... } } + if (is_array($payload)) { + $this->ingestArray($payload); + return; + } + + // Legacy simple usage: Livewire.dispatch('showNotification', 'Quick saved!') + if (is_string($type) && $message === null && $title === null) { + $this->pushAlert([ + 'type' => 'success', + 'message' => $type, + 'tag' => $tag, + ]); + return; + } + + // Must have a type + message at minimum + if (!$type || !$message) { + return; + } + + $this->pushAlert([ + 'type' => $type, + 'message' => $message, + 'title' => $title, + 'description' => $description, + 'icon' => $icon, + 'html' => (bool) $html, + 'tag' => $tag, + ]); + } + + /** + * Ingest an associative array payload (supports multiple key name variants). + */ + protected function ingestArray(array $arr): void + { + $this->pushAlert([ + 'type' => $arr['type'] ?? $arr['level'] ?? 'info', + 'message' => $arr['message'] ?? $arr['msg'] ?? null, + 'title' => $arr['title'] ?? $arr['heading'] ?? null, + 'description' => $arr['description'] ?? $arr['desc'] ?? null, + 'icon' => $arr['icon'] ?? null, + 'html' => (bool) ($arr['html'] ?? false), + 'tag' => $arr['tag'] ?? null, + ]); + } + + /** + * Normalize a semantic type into a Bootstrap alert class fragment. + */ + protected function normalizeType(string $type): string + { + return match (strtolower($type)) { + 'error', 'danger', 'fail', 'failed' => 'danger', + 'ok', 'status' => 'success', + default => strtolower($type), + }; + } + + /** + * Provide default icon classes if none supplied. + */ + protected function defaultIcon(string $type): string + { + return match ($type) { + 'success' => 'fas fa-check faa-pulse animated', + 'danger' => 'fas fa-exclamation-triangle faa-pulse animated', + 'warning' => 'fas fa-exclamation-triangle faa-pulse animated', + default => 'fas fa-info-circle faa-pulse animated', + }; + } + + /** + * Insert or replace an alert (if tag provided & already exists). + */ + protected function pushAlert(array $data): void + { + if (empty($data['message'])) { + return; + } + + $alert = $this->buildAlert($data); + + if ($alert['tag'] !== null && $this->replaceTaggedAlert($alert)) { + return; + } + + $this->addAlert($alert); + } + + protected function buildAlert(array $data): array + { + $type = $this->normalizeType($data['type'] ?? 'info'); + + return [ + 'id' => uniqid('al_', true), + 'type' => $type, + 'tag' => $data['tag'] ?? null, + 'title' => $data['title'] ?? null, + 'message' => $data['message'], + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? $this->defaultIcon($type), + 'html' => $data['html'] ?? false, + 'created_at' => time(), + ]; + } + + protected function replaceTaggedAlert(array $alert): bool + { + foreach ($this->liveAlerts as $index => $liveAlert) { + if ($liveAlert['tag'] === $alert['tag']) { + $this->liveAlerts[$index] = $alert; + return true; + } + } + + return false; + } + + protected function addAlert(array $alert): void + { + $this->liveAlerts[] = $alert; + } + + /** + * Dismiss everything (add a button if you want). + */ + #[On('dismissAllNotifications')] + public function dismissAll(): void + { + $this->liveAlerts = []; + } + + public function render() + { + return view('livewire.notifications'); + } +} \ No newline at end of file diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php new file mode 100644 index 000000000000..2d26f2be6798 --- /dev/null +++ b/resources/views/components/alert.blade.php @@ -0,0 +1,45 @@ +@props([ + 'type' => 'info', // success | danger | warning | info | error + 'icon' => null, + 'heading' => null, + 'html' => false, + 'confetti' => false, + 'id' => null, +]) + +@php + // Normalize "error" to Bootstrap's "danger" + $normalized = $type === 'error' ? 'danger' : $type; + + // Default icon set (Font Awesome 5 in your project already) + $iconClass = $icon ?? match($normalized) { + 'success' => 'fas fa-check faa-pulse animated', + 'danger' => 'fas fa-exclamation-triangle faa-pulse animated', + 'warning' => 'fas fa-exclamation-triangle faa-pulse animated', + default => 'fas fa-info-circle faa-pulse animated', + }; + + $wrapperId = $id ? 'id="'.$id.'"' : ''; +@endphp + +
+
+ + @if($iconClass) + + @endif + @if($heading) + {{ $heading }}: + @endif + + @if($html) + {!! $slot !!} + @else + {{ $slot }} + @endif +
+
+ +@if($confetti) + @include('partials.confetti-js') +@endif \ No newline at end of file diff --git a/resources/views/layouts/default.blade.php b/resources/views/layouts/default.blade.php index 419dc804ed16..93286266a728 100644 --- a/resources/views/layouts/default.blade.php +++ b/resources/views/layouts/default.blade.php @@ -1657,7 +1657,7 @@ @endif - @include('notifications') + diff --git a/resources/views/livewire/notifications.blade.php b/resources/views/livewire/notifications.blade.php new file mode 100644 index 000000000000..2939b8a0568e --- /dev/null +++ b/resources/views/livewire/notifications.blade.php @@ -0,0 +1,21 @@ +
+ {{-- Existing redirect/session flashes --}} + @include('notifications') + + {{-- Live (dynamic) alerts --}} + @foreach($liveAlerts as $alert) + @include('partials.live-alert', ['alert' => $alert]) + @endforeach + + {{-- Javascript --}} + @script + + @endscript + +
\ No newline at end of file diff --git a/resources/views/notifications.blade.php b/resources/views/notifications.blade.php index 0bb60c689e36..8cf4354f9f33 100755 --- a/resources/views/notifications.blade.php +++ b/resources/views/notifications.blade.php @@ -1,195 +1,136 @@ -@if ($errors->any()) -
-
- - - {{ trans('general.notification_error') }}: - {{ trans('general.notification_error_hint') }} -
-
+@php + $pull = function(string $key) { + return session()->has($key) ? session()->get($key) : null; + }; +@endphp +@if ($errors->any()) + + {{ trans('general.notification_error_hint') }} + @endif - -@if ($message = session()->get('status')) -
-
- - - {{ trans('general.notification_success') }}: - {{ $message }} -
-
+@if ($msg = $pull('status')) + + {{ $msg }} + @endif - -@if ($message = session()->get('success')) -
-
- - - {{ trans('general.notification_success') }}: - {{ $message }} -
-
-@include ('partials.confetti-js') +@if ($msg = $pull('success')) + + {{ $msg }} + @endif - -@if ($message = session()->get('success-unescaped')) -
-
- - - {{ trans('general.notification_success') }}: - {!! $message !!} -
-
- @include ('partials.confetti-js') +@if ($msg = $pull('success-unescaped')) + + {!! $msg !!} + @endif - -@if ($assets = session()->get('assets')) +@if ($assets = $pull('assets')) @foreach ($assets as $asset) -
-
- - - {{ trans('general.asset_information') }}: -
    - @isset ($asset->model->name) -
  • {{ trans('general.model_name') }} {{ $asset->model->name }}
  • - @endisset - @isset ($asset->name) -
  • {{ trans('general.asset_name') }} {{ $asset->model->name }}
  • - @endisset -
  • {{ trans('general.asset_tag') }} {{ $asset->asset_tag }}
  • - @isset ($asset->notes) -
  • {{ trans('general.notes') }} {{ $asset->notes }}
  • - @endisset -
- -
-
+ +
    + @isset($asset->model->name) +
  • {{ trans('general.model_name') }} {{ $asset->model->name }}
  • + @endisset + @isset($asset->name) +
  • {{ trans('general.asset_name') }} {{ $asset->name }}
  • + @endisset +
  • {{ trans('general.asset_tag') }} {{ $asset->asset_tag }}
  • + @isset($asset->notes) +
  • {{ trans('general.notes') }} {{ $asset->notes }}
  • + @endisset +
+
@endforeach @endif - -@if ($consumables = session()->get('consumables')) +@if ($consumables = $pull('consumables')) @foreach ($consumables as $consumable) -
-
- - - {{ trans('general.consumable_information') }}: -
  • {{ trans('general.consumable_name') }} {{ $consumable->name }}
-
-
+ +
    +
  • {{ trans('general.consumable_name') }} {{ $consumable->name }}
  • +
+
@endforeach @endif - -@if ($accessories = session()->get('accessories')) +@if ($accessories = $pull('accessories')) @foreach ($accessories as $accessory) -
-
- - - {{ trans('general.accessory_information') }}: -
  • {{ trans('general.accessory_name') }} {{ $accessory->name }}
-
-
+ +
    +
  • {{ trans('general.accessory_name') }} {{ $accessory->name }}
  • +
+
@endforeach @endif - -@if ($message = session()->get('error')) -
-
- - - {{ trans('general.error') }}: - {{ $message }} -
-
+@if ($msg = $pull('error')) + + {{ $msg }} + @endif - -@if ($messages = session()->get('error_messages')) -@foreach ($messages as $message) -
-
- - - {{ trans('general.notification_error') }}: - {{ $message }} -
-
-@endforeach +@if ($messages = $pull('error_messages')) + @foreach ($messages as $message) + + {{ $message }} + + @endforeach @endif - -@if ($messages = session()->get('bulk_asset_errors')) -
-
- - - {{ trans('general.notification_error') }}: - {{ trans('general.notification_bulk_error_hint') }} - @foreach($messages as $key => $message) - @for ($x = 0; $x < count($message); $x++) -
    -
  • {{ $message[$x] }}
  • +@if ($bulk = $pull('bulk_asset_errors')) + + {{ trans('general.notification_bulk_error_hint') }} + @foreach ($bulk as $key => $set) + @foreach ($set as $entry) +
      +
    • {{ $entry }}
    - @endfor @endforeach -
-
+ @endforeach + @endif @if ($messages = session()->get('multi_error_messages'))
-
+
{{ trans('general.notification_error') }}:
    - @foreach(array_splice($messages, 0,3) as $key => $message) + @foreach(array_splice($messages, 0, 3) as $message)
  • {{ $message }}
  • @endforeach
-
- {{ trans('general.show_all') }} -
    - @foreach(array_splice($messages, 3) as $key => $message) -
  • {{ $message }}
  • - @endforeach -
-
+ @if (count($messages) > 0) +
+ {{ trans('general.show_all') }} +
    + @foreach($messages as $message) +
  • {{ $message }}
  • + @endforeach +
+
+ @endif
@endif - -@if ($message = session()->get('warning')) -
-
- - - {{ trans('general.notification_warning') }}: - {{ $message }} -
-
+@if ($msg = $pull('warning')) + + {{ $msg }} + @endif - -@if ($message = session()->get('info')) -
-
- - - {{ trans('general.notification_info') }}: - {{ $message }} -
-
+@if ($msg = $pull('info')) + + {{ $msg }} + @endif diff --git a/resources/views/partials/live-alert.blade.php b/resources/views/partials/live-alert.blade.php new file mode 100644 index 000000000000..627e406d8a64 --- /dev/null +++ b/resources/views/partials/live-alert.blade.php @@ -0,0 +1,37 @@ +@php + $id = $alert['id']; + $type = $alert['type']; // already normalized (success|danger|warning|info) + $icon = $alert['icon'] ?? null; + $title = $alert['title'] ?? null; + $message = $alert['message'] ?? ''; + $description = $alert['description'] ?? null; + $html = $alert['html'] ?? false; +@endphp + +
+
+ + @if($icon) + + @endif + @if($title) + {{ $title }}@if(!$html && $message){{ ': ' }}@endif + @endif + + @if($html) + {!! $message !!} + @else + {{ $message }} + @endif + + @if($description) +
+ @if($html) + {!! $description !!} + @else + {{ $description }} + @endif +
+ @endif +
+
\ No newline at end of file diff --git a/tests/Feature/NotificationsComponentTest.php b/tests/Feature/NotificationsComponentTest.php new file mode 100644 index 000000000000..21b34732bc04 --- /dev/null +++ b/tests/Feature/NotificationsComponentTest.php @@ -0,0 +1,98 @@ +call('notify', 'success', 'Saved!') + ->assertSee('Saved!') + ->assertSee('alert-success'); + } + + public function testItCanAddDynamicNotificationWithTitleDescriptionAndAcon() + { + Livewire::test('notifications') + ->call('notify', 'success', 'Saved!', 'Asset Created', 'MacBook added', 'fas fa-laptop') + ->assertSee('Asset Created') + ->assertSee('MacBook added') + ->assertSee('fas fa-laptop') + ->assertSee('alert-success'); + } + + public function testItCanAddDynamicNotificationWithHtmlMessage() + { + Livewire::test('notifications') + ->call('notify', 'success', 'Bold text', null, null, null, true) + ->assertSeeHtml('Bold text'); + } + + public function testItCanReplaceDynamicNotificationByTag() + { + $component = Livewire::test('notifications') + ->call('notify', 'info', 'First message', null, null, null, false, false, 'progress'); + + $component->assertSee('First message'); + + $component->call('notify', 'info', 'Second message', null, null, null, false, false, 'progress'); + $component->assertSee('Second message'); + $component->assertDontSee('First message'); + } + + public function testLegacySessionSuccessNotificationIsRendered() + { + Session::flash('success', 'Legacy flash success!'); + Livewire::test('notifications') + ->assertSee('Legacy flash success!') + ->assertSee('alert-success'); + } + + public function testLegacySessionErrorNotificationIsRendered() + { + Session::flash('error', 'Legacy error!'); + Livewire::test('notifications') + ->assertSee('Legacy error!') + ->assertSee('alert-danger'); + } + + public function testLegacySessionSuccessUnescapedNotificationIsRendered() + { + Session::flash('success-unescaped', 'Legacy Unescaped'); + Livewire::test('notifications') + ->assertSeeHtml('Legacy Unescaped'); + } + + public function testLegacySessionWarningNotificationIsRendered() + { + Session::flash('warning', 'Legacy warning!'); + Livewire::test('notifications') + ->assertSee('Legacy warning!') + ->assertSee('alert-warning'); + } + + public function testLegacySessionInfoNotificationIsRendered() + { + Session::flash('info', 'Legacy info!'); + Livewire::test('notifications') + ->assertSee('Legacy info!') + ->assertSee(values: 'alert-info'); + } + + public function testLegacySessionBulkAssetErrorsAreRendered() + { + Session::flash('bulk_asset_errors', [ + 'row1' => ['Missing tag'], + 'row2' => ['Model not found', 'Serial required'], + ]); + Livewire::test('notifications') + ->assertSee('Missing tag') + ->assertSee('Model not found') + ->assertSee('Serial required'); + } +} \ No newline at end of file