Skip to content

Commit d5c3545

Browse files
committed
feat: enhance profile photo handling with new trait
1 parent af4b088 commit d5c3545

File tree

8 files changed

+204
-103
lines changed

8 files changed

+204
-103
lines changed

app/Http/Controllers/Settings/ProfileController.php

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,18 @@ public function edit(Request $request): Response
3030
*/
3131
public function update(ProfileUpdateRequest $request): RedirectResponse
3232
{
33-
$user = $request->user();
34-
$user->fill($request->validated());
33+
$request->user()->fill($request->validated());
3534

36-
if ($user->isDirty('email')) {
37-
$user->email_verified_at = null;
35+
if ($request->user()->isDirty('email')) {
36+
$request->user()->email_verified_at = null;
3837
}
3938

40-
if ($request->hasFile('photo')) {
41-
// Delete old photo if exists
42-
if ($user->profile_photo_path) {
43-
Storage::disk('public')->delete($user->profile_photo_path);
44-
}
39+
$request->user()->save();
4540

46-
// Store new photo
47-
$photoPath = $request->file('photo')->store('avatars', 'public');
48-
$user->profile_photo_path = $photoPath;
41+
if ($request->hasFile('photo')) {
42+
$request->user()->updateProfilePhoto($request->validated('photo'));
4943
}
5044

51-
$user->save();
52-
5345
return to_route('profile.edit');
5446
}
5547

@@ -66,6 +58,8 @@ public function destroy(Request $request): RedirectResponse
6658

6759
Auth::logout();
6860

61+
$user->deleteProfilePhoto();
62+
6963
$user->delete();
7064

7165
$request->session()->invalidate();

app/Http/Controllers/Settings/ProfilePhotoController.php

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,18 @@
55
use App\Http\Controllers\Controller;
66
use Illuminate\Http\RedirectResponse;
77
use Illuminate\Http\Request;
8-
use Illuminate\Support\Facades\Storage;
98

109
class ProfilePhotoController extends Controller
1110
{
1211
/**
1312
* Delete the current user's profile photo.
13+
*
14+
* @param \Illuminate\Http\Request $request
15+
* @return \Illuminate\Http\RedirectResponse
1416
*/
1517
public function destroy(Request $request): RedirectResponse
1618
{
17-
$path = $request->user()->profile_photo_path;
18-
if ($path && Storage::disk('public')->exists($path)) {
19-
Storage::disk('public')->delete($path);
20-
}
21-
22-
$request->user()->profile_photo_path = null;
23-
$request->user()->save();
19+
$request->user()->deleteProfilePhoto();
2420

2521
return to_route('profile.edit');
2622
}

app/Http/Requests/Settings/ProfileUpdateRequest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function rules(): array
2828
Rule::unique(User::class)->ignore($this->user()->id),
2929
],
3030

31-
'photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:1024'],
31+
'photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:2048'],
3232
];
3333
}
3434
}

app/Models/User.php

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
use Illuminate\Database\Eloquent\Factories\HasFactory;
77
use Illuminate\Foundation\Auth\User as Authenticatable;
88
use Illuminate\Notifications\Notifiable;
9-
use Illuminate\Support\Facades\Storage;
9+
use App\Traits\HasProfilePhoto;
1010

1111
class User extends Authenticatable
1212
{
1313
/** @use HasFactory<\Database\Factories\UserFactory> */
14-
use HasFactory, Notifiable;
14+
use HasFactory, Notifiable, HasProfilePhoto;
1515

1616
/**
1717
* The attributes that are mass assignable.
@@ -35,9 +35,9 @@ class User extends Authenticatable
3535
];
3636

3737
/**
38-
* The attributes that should be appended to the model's array form.
38+
* The accessors to append to the model's array form.
3939
*
40-
* @var list<string>
40+
* @var array<int, string>
4141
*/
4242
protected $appends = [
4343
'avatar',
@@ -55,18 +55,4 @@ protected function casts(): array
5555
'password' => 'hashed',
5656
];
5757
}
58-
59-
/**
60-
* Get the URL of the user's profile photo.
61-
*
62-
* @return string|null
63-
*/
64-
public function getAvatarAttribute(): ?string
65-
{
66-
if ($this->profile_photo_path) {
67-
return Storage::url($this->profile_photo_path);
68-
}
69-
70-
return null;
71-
}
7258
}

app/Traits/HasProfilePhoto.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace App\Traits;
4+
5+
use Illuminate\Database\Eloquent\Casts\Attribute;
6+
use Illuminate\Http\UploadedFile;
7+
use Illuminate\Support\Facades\Storage;
8+
9+
trait HasProfilePhoto
10+
{
11+
/**
12+
* Update the user's profile photo.
13+
*
14+
* @param \Illuminate\Http\UploadedFile $photo
15+
* @param string $storagePath
16+
* @return void
17+
*/
18+
public function updateProfilePhoto(UploadedFile $photo, $storagePath = 'profile-photos'): void
19+
{
20+
tap($this->profile_photo_path, function ($previous) use ($photo, $storagePath) {
21+
$this->forceFill([
22+
'profile_photo_path' => $photo->storePublicly(
23+
$storagePath, ['disk' => $this->profilePhotoDisk()]
24+
),
25+
])->save();
26+
27+
if ($previous) {
28+
Storage::disk($this->profilePhotoDisk())->delete($previous);
29+
}
30+
});
31+
}
32+
33+
/**
34+
* Delete the user's profile photo.
35+
*
36+
* @return void
37+
*/
38+
public function deleteProfilePhoto(): void
39+
{
40+
if (is_null($this->profile_photo_path)) {
41+
return;
42+
}
43+
44+
Storage::disk($this->profilePhotoDisk())->delete($this->profile_photo_path);
45+
46+
$this->forceFill([
47+
'profile_photo_path' => null,
48+
])->save();
49+
}
50+
51+
/**
52+
* Get the URL to the user's profile photo.
53+
*
54+
* @return \Illuminate\Database\Eloquent\Casts\Attribute
55+
*/
56+
protected function avatar(): Attribute
57+
{
58+
return Attribute::make(
59+
get: function ($value) {
60+
return $this->profile_photo_path
61+
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
62+
: null;
63+
},
64+
set: function ($value) {
65+
return ['profile_photo_path' => $value];
66+
}
67+
);
68+
}
69+
70+
/**
71+
* Get the disk that profile photos should be stored on.
72+
*
73+
* @return string
74+
*/
75+
protected function profilePhotoDisk(): string
76+
{
77+
return 'public';
78+
}
79+
}

resources/js/pages/settings/profile.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { FormEventHandler, useRef, useState } from 'react';
66
import DeleteUser from '@/components/delete-user';
77
import HeadingSmall from '@/components/heading-small';
88
import InputError from '@/components/input-error';
9-
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
109
import { Button } from '@/components/ui/button';
1110
import { Input } from '@/components/ui/input';
1211
import { Label } from '@/components/ui/label';
13-
import { useInitials } from '@/hooks/use-initials';
1412
import AppLayout from '@/layouts/app-layout';
1513
import SettingsLayout from '@/layouts/settings/layout';
14+
import { useInitials } from '@/hooks/use-initials';
15+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1616

1717
const breadcrumbs: BreadcrumbItem[] = [
1818
{
@@ -21,7 +21,7 @@ const breadcrumbs: BreadcrumbItem[] = [
2121
},
2222
];
2323

24-
interface ProfileForm {
24+
type ProfileForm = {
2525
_method: string;
2626
name: string;
2727
email: string;
@@ -33,7 +33,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
3333
const getInitials = useInitials();
3434

3535
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
36-
const photoInput = useRef<HTMLInputElement>(null);
36+
const photoInput = useRef<HTMLInputElement | null>(null);
3737

3838
const { data, setData, post, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
3939
_method: 'patch',
@@ -80,6 +80,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
8080

8181
const submit: FormEventHandler = (e) => {
8282
e.preventDefault();
83+
8384
post(route('profile.update'), {
8485
preserveScroll: true,
8586
onSuccess: () => clearPhotoFileInput(),
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace Tests\Feature\Settings;
4+
5+
use App\Models\User;
6+
use Illuminate\Foundation\Testing\RefreshDatabase;
7+
use Tests\TestCase;
8+
use Illuminate\Http\UploadedFile;
9+
use Illuminate\Support\Facades\Storage;
10+
11+
class ProfilePhotoTest extends TestCase
12+
{
13+
use RefreshDatabase;
14+
15+
public function test_profile_photo_can_be_uploaded()
16+
{
17+
$user = User::factory()->create();
18+
19+
Storage::fake('public');
20+
21+
$response = $this
22+
->actingAs($user)
23+
->patch('/settings/profile', [
24+
'name' => $user->name,
25+
'email' => $user->email,
26+
'photo' => $file = UploadedFile::fake()->image('photo.jpg'),
27+
]);
28+
29+
$response
30+
->assertSessionHasNoErrors()
31+
->assertRedirect('/settings/profile');
32+
33+
$user->refresh();
34+
35+
$this->assertNotNull($user->profile_photo_path);
36+
$this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path));
37+
}
38+
39+
public function test_profile_photo_can_be_removed()
40+
{
41+
$user = User::factory()->create();
42+
43+
Storage::fake('public');
44+
45+
$response = $this->actingAs($user)->patch('/settings/profile', [
46+
'name' => $user->name,
47+
'email' => $user->email,
48+
'photo' => $file = UploadedFile::fake()->image('photo.jpg'),
49+
]);
50+
51+
$response->assertSessionHasNoErrors()
52+
->assertRedirect('/settings/profile');
53+
54+
$user->refresh();
55+
56+
$this->assertNotNull($user->profile_photo_path);
57+
$this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path));
58+
59+
$oldPath = $user->profile_photo_path;
60+
61+
$response = $this->actingAs($user)->delete('/settings/profile-photo');
62+
63+
$response->assertSessionHasNoErrors()
64+
->assertRedirect('/settings/profile');
65+
66+
$user->refresh();
67+
68+
$this->assertNull($user->profile_photo_path);
69+
$this->assertFalse(Storage::disk('public')->exists($oldPath));
70+
}
71+
72+
public function test_profile_photo_can_be_updated()
73+
{
74+
$user = User::factory()->create();
75+
76+
Storage::fake('public');
77+
78+
$this->actingAs($user)->patch('/settings/profile', [
79+
'name' => $user->name,
80+
'email' => $user->email,
81+
'photo' => UploadedFile::fake()->image('initial.jpg'),
82+
]);
83+
84+
$user->refresh();
85+
$oldPath = $user->profile_photo_path;
86+
87+
$response = $this->actingAs($user)->patch('/settings/profile', [
88+
'name' => $user->name,
89+
'email' => $user->email,
90+
'photo' => UploadedFile::fake()->image('updated.jpg'),
91+
]);
92+
93+
$response->assertSessionHasNoErrors()
94+
->assertRedirect('/settings/profile');
95+
96+
$user->refresh();
97+
98+
$this->assertNotNull($user->profile_photo_path);
99+
$this->assertNotEquals($oldPath, $user->profile_photo_path);
100+
$this->assertTrue(Storage::disk('public')->exists($user->profile_photo_path));
101+
$this->assertFalse(Storage::disk('public')->exists($oldPath));
102+
}
103+
}

0 commit comments

Comments
 (0)