Skip to content

Commit 9366557

Browse files
simonhampclaude
andcommitted
Add database notifications system with bell icon in dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1ab38e commit 9366557

6 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Livewire\Customer;
4+
5+
use Livewire\Attributes\Computed;
6+
use Livewire\Attributes\Layout;
7+
use Livewire\Attributes\Title;
8+
use Livewire\Component;
9+
use Livewire\WithPagination;
10+
11+
#[Layout('components.layouts.dashboard')]
12+
#[Title('Notifications')]
13+
class Notifications extends Component
14+
{
15+
use WithPagination;
16+
17+
#[Computed]
18+
public function notifications(): \Illuminate\Contracts\Pagination\LengthAwarePaginator
19+
{
20+
return auth()->user()->notifications()->latest()->paginate(20);
21+
}
22+
23+
public function markAsRead(string $id): void
24+
{
25+
auth()->user()->notifications()->where('id', $id)->first()?->markAsRead();
26+
}
27+
28+
public function markAllAsRead(): void
29+
{
30+
auth()->user()->unreadNotifications->markAsRead();
31+
}
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('notifications', function (Blueprint $table) {
15+
$table->uuid('id')->primary();
16+
$table->string('type');
17+
$table->morphs('notifiable');
18+
$table->text('data');
19+
$table->timestamp('read_at')->nullable();
20+
$table->timestamps();
21+
});
22+
}
23+
24+
/**
25+
* Reverse the migrations.
26+
*/
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('notifications');
30+
}
31+
};

resources/views/components/layouts/dashboard.blade.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
137137

138138
<flux:sidebar.spacer />
139139

140+
@php $unreadCount = auth()->user()->unreadNotifications()->count(); @endphp
141+
140142
<flux:sidebar.nav>
143+
<flux:sidebar.item icon="bell" href="{{ route('customer.notifications') }}" :current="request()->routeIs('customer.notifications')" :badge="$unreadCount > 0 ? $unreadCount : null">
144+
Notifications
145+
</flux:sidebar.item>
146+
141147
<flux:sidebar.item icon="link" href="{{ route('customer.integrations') }}" :current="request()->routeIs('customer.integrations')">
142148
Integrations
143149
</flux:sidebar.item>
@@ -170,6 +176,13 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
170176

171177
<flux:spacer />
172178

179+
<a href="{{ route('customer.notifications') }}" class="relative p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200">
180+
<x-heroicon-o-bell class="size-5" />
181+
@if ($unreadCount > 0)
182+
<span class="absolute right-1 top-1 size-2 rounded-full bg-blue-500"></span>
183+
@endif
184+
</a>
185+
173186
<flux:dropdown position="top" align="start">
174187
<flux:profile name="{{ auth()->user()->name ?? auth()->user()->email }}" />
175188

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<div class="mx-auto max-w-2xl">
2+
<div class="mb-6 flex items-center justify-between">
3+
<div>
4+
<flux:heading size="xl">Notifications</flux:heading>
5+
<flux:text>Stay up to date with your account activity.</flux:text>
6+
</div>
7+
8+
@if (auth()->user()->unreadNotifications->count() > 0)
9+
<flux:button wire:click="markAllAsRead" variant="ghost" size="sm">
10+
Mark all as read
11+
</flux:button>
12+
@endif
13+
</div>
14+
15+
@forelse ($this->notifications as $notification)
16+
<flux:card wire:key="notification-{{ $notification->id }}" class="mb-3">
17+
<div class="flex items-start gap-3">
18+
{{-- Unread indicator --}}
19+
<div class="mt-1.5 shrink-0">
20+
@if (is_null($notification->read_at))
21+
<div class="size-2.5 rounded-full bg-blue-500"></div>
22+
@else
23+
<div class="size-2.5"></div>
24+
@endif
25+
</div>
26+
27+
<div class="min-w-0 flex-1">
28+
<div class="flex items-start justify-between gap-2">
29+
<flux:heading size="sm" class="{{ is_null($notification->read_at) ? 'font-semibold' : 'font-normal' }}">
30+
{{ $notification->data['title'] ?? 'Notification' }}
31+
</flux:heading>
32+
<flux:text class="shrink-0 text-xs">
33+
{{ $notification->created_at->diffForHumans() }}
34+
</flux:text>
35+
</div>
36+
37+
@if (! empty($notification->data['body']))
38+
<flux:text class="mt-1">{{ $notification->data['body'] }}</flux:text>
39+
@endif
40+
41+
@if (is_null($notification->read_at))
42+
<div class="mt-2">
43+
<flux:button wire:click="markAsRead('{{ $notification->id }}')" variant="ghost" size="xs">
44+
Mark as read
45+
</flux:button>
46+
</div>
47+
@endif
48+
</div>
49+
</div>
50+
</flux:card>
51+
@empty
52+
<flux:card>
53+
<div class="py-8 text-center">
54+
<flux:icon.bell class="mx-auto mb-3 size-8 text-zinc-400" />
55+
<flux:heading size="sm">No notifications</flux:heading>
56+
<flux:text>You're all caught up!</flux:text>
57+
</div>
58+
</flux:card>
59+
@endforelse
60+
61+
<div class="mt-4">
62+
{{ $this->notifications->links() }}
63+
</div>
64+
</div>

routes/web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@
299299
// Settings page
300300
Route::livewire('settings', \App\Livewire\Customer\Settings::class)->name('settings');
301301

302+
// Notifications page
303+
Route::livewire('notifications', \App\Livewire\Customer\Notifications::class)->name('notifications');
304+
302305
// License list page
303306
Route::livewire('licenses', \App\Livewire\Customer\Licenses\Index::class)->name('licenses.list');
304307
Route::livewire('integrations', \App\Livewire\Customer\Integrations::class)->name('integrations');
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
namespace Tests\Feature\Livewire\Customer;
4+
5+
use App\Features\ShowAuthButtons;
6+
use App\Livewire\Customer\Notifications;
7+
use App\Models\User;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Notifications\DatabaseNotification;
10+
use Illuminate\Support\Str;
11+
use Laravel\Pennant\Feature;
12+
use Livewire\Livewire;
13+
use Tests\TestCase;
14+
15+
class NotificationsTest extends TestCase
16+
{
17+
use RefreshDatabase;
18+
19+
protected function setUp(): void
20+
{
21+
parent::setUp();
22+
23+
Feature::define(ShowAuthButtons::class, true);
24+
}
25+
26+
// --- Page rendering ---
27+
28+
public function test_notifications_page_renders_successfully(): void
29+
{
30+
$user = User::factory()->create();
31+
32+
$response = $this->withoutVite()->actingAs($user)->get('/dashboard/notifications');
33+
34+
$response->assertStatus(200);
35+
}
36+
37+
public function test_notifications_page_requires_authentication(): void
38+
{
39+
$response = $this->withoutVite()->get('/dashboard/notifications');
40+
41+
$response->assertRedirect('/login');
42+
}
43+
44+
public function test_notifications_component_renders_headings(): void
45+
{
46+
$user = User::factory()->create();
47+
48+
Livewire::actingAs($user)
49+
->test(Notifications::class)
50+
->assertSee('Notifications')
51+
->assertSee('Stay up to date with your account activity.')
52+
->assertStatus(200);
53+
}
54+
55+
// --- Displaying notifications ---
56+
57+
public function test_notifications_display_in_reverse_chronological_order(): void
58+
{
59+
$user = User::factory()->create();
60+
61+
$this->createNotification($user, ['title' => 'First'], now()->subHours(2));
62+
$this->createNotification($user, ['title' => 'Second'], now()->subHour());
63+
$this->createNotification($user, ['title' => 'Third'], now());
64+
65+
Livewire::actingAs($user)
66+
->test(Notifications::class)
67+
->assertSeeInOrder(['Third', 'Second', 'First']);
68+
}
69+
70+
public function test_empty_state_shown_when_no_notifications(): void
71+
{
72+
$user = User::factory()->create();
73+
74+
Livewire::actingAs($user)
75+
->test(Notifications::class)
76+
->assertSee('No notifications')
77+
->assertSee("You're all caught up!", escape: false);
78+
}
79+
80+
public function test_notification_title_and_body_are_displayed(): void
81+
{
82+
$user = User::factory()->create();
83+
84+
$this->createNotification($user, [
85+
'title' => 'License Renewed',
86+
'body' => 'Your license has been renewed successfully.',
87+
]);
88+
89+
Livewire::actingAs($user)
90+
->test(Notifications::class)
91+
->assertSee('License Renewed')
92+
->assertSee('Your license has been renewed successfully.');
93+
}
94+
95+
// --- Mark as read ---
96+
97+
public function test_mark_single_notification_as_read(): void
98+
{
99+
$user = User::factory()->create();
100+
101+
$notification = $this->createNotification($user, ['title' => 'Test']);
102+
103+
$this->assertNull($notification->read_at);
104+
105+
Livewire::actingAs($user)
106+
->test(Notifications::class)
107+
->call('markAsRead', $notification->id);
108+
109+
$this->assertNotNull($notification->fresh()->read_at);
110+
}
111+
112+
public function test_mark_all_as_read(): void
113+
{
114+
$user = User::factory()->create();
115+
116+
$n1 = $this->createNotification($user, ['title' => 'First']);
117+
$n2 = $this->createNotification($user, ['title' => 'Second']);
118+
119+
Livewire::actingAs($user)
120+
->test(Notifications::class)
121+
->call('markAllAsRead');
122+
123+
$this->assertNotNull($n1->fresh()->read_at);
124+
$this->assertNotNull($n2->fresh()->read_at);
125+
}
126+
127+
public function test_mark_all_as_read_button_hidden_when_none_unread(): void
128+
{
129+
$user = User::factory()->create();
130+
131+
$this->createNotification($user, ['title' => 'Read one'], now(), now());
132+
133+
Livewire::actingAs($user)
134+
->test(Notifications::class)
135+
->assertDontSee('Mark all as read');
136+
}
137+
138+
public function test_mark_all_as_read_button_shown_when_unread_exist(): void
139+
{
140+
$user = User::factory()->create();
141+
142+
$this->createNotification($user, ['title' => 'Unread one']);
143+
144+
Livewire::actingAs($user)
145+
->test(Notifications::class)
146+
->assertSee('Mark all as read');
147+
}
148+
149+
// --- Bell icon in layout ---
150+
151+
public function test_bell_icon_shows_in_dashboard_layout(): void
152+
{
153+
$user = User::factory()->create();
154+
155+
$response = $this->withoutVite()->actingAs($user)->get('/dashboard/settings');
156+
157+
$response->assertStatus(200);
158+
$response->assertSee(route('customer.notifications'));
159+
}
160+
161+
/**
162+
* Create a database notification for a user.
163+
*/
164+
private function createNotification(User $user, array $data, ?\Carbon\Carbon $createdAt = null, ?\Carbon\Carbon $readAt = null): DatabaseNotification
165+
{
166+
return DatabaseNotification::create([
167+
'id' => Str::uuid()->toString(),
168+
'type' => 'App\\Notifications\\TestNotification',
169+
'notifiable_type' => User::class,
170+
'notifiable_id' => $user->id,
171+
'data' => $data,
172+
'read_at' => $readAt,
173+
'created_at' => $createdAt ?? now(),
174+
'updated_at' => $createdAt ?? now(),
175+
]);
176+
}
177+
}

0 commit comments

Comments
 (0)