Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,5 @@
'tenant_id' => $tenant->getKey(),
'character_id' => $user->character->id,
'badge_id' => $badge->id,
'claimed_at' => now(),
]);
});
176 changes: 168 additions & 8 deletions app-modules/user/src/Filament/User/Pages/UserProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use Filament\Auth\Notifications\NoticeOfEmailChangeRequest;
use Filament\Auth\Notifications\VerifyEmailChange;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification as FilamentNotification;
use Filament\Pages\Concerns\CanUseDatabaseTransactions;
Expand Down Expand Up @@ -42,6 +44,7 @@
use Illuminate\Validation\Rules\Password;
use League\Uri\Components\Query;
use LogicException;
use OtavioAraujo\FilamentSmartCep\Forms\Components\SmartCep;
use Throwable;

/**
Expand All @@ -58,6 +61,10 @@ final class UserProfile extends Page
*/
public ?array $data = [];

public ?array $informationData = [];

public ?array $addressData = [];

protected static bool $isDiscovered = false;

protected string $view;
Expand Down Expand Up @@ -100,13 +107,19 @@ public function getView(): string
public function mount(): void
{
$this->fillForm();

$user = $this->getUser();

$this->informationData = $user->information?->toArray() ?? [];
$this->addressData = $user->address?->toArray() ?? [];
}

public function getUser(): Authenticatable&Model
{
$user = Filament::auth()->user();

throw_unless($user instanceof Model, LogicException::class, 'The authenticated user object must be an Eloquent model to allow the profile page to update it.');
throw_unless($user instanceof Model, LogicException::class,
'The authenticated user object must be an Eloquent model to allow the profile page to update it.');

return $user;
}
Expand Down Expand Up @@ -159,6 +172,56 @@ public function save(): void
}
}

public function saveInformation(): void
{
$this->validate([
'informationData.name' => ['required', 'string', 'max:255'],
Comment thread
danielhe4rt marked this conversation as resolved.
'informationData.nickname' => ['nullable', 'string', 'max:255'],
'informationData.linkedin_url' => ['nullable', 'string', 'url', 'max:255'],
'informationData.github_url' => ['nullable', 'string', 'url', 'max:255'],
'informationData.birthdate' => ['nullable', 'date'],
'informationData.about' => ['nullable', 'string', 'max:1000'],
]);

$user = $this->getUser();

$user->information()->updateOrCreate(
['user_id' => $user->id],
$this->informationData
);

FilamentNotification::make()
->title('Information updated successfully.')
->success()
->send();
}

public function saveAddress(): void
{
$this->validate([
'addressData.zip_code' => [
'required',
'string',
'regex:/^\d{5}-\d{3}$/',
],
'addressData.country' => ['nullable', 'string', 'max:255'],
Comment thread
danielhe4rt marked this conversation as resolved.
'addressData.state' => ['nullable', 'string', 'max:255'],
'addressData.city' => ['nullable', 'string', 'max:255'],
]);

$user = $this->getUser();

$user->address()->updateOrCreate(
['user_id' => $user->id],
$this->addressData
);

FilamentNotification::make()
->title('Address updated successfully.')
->success()
->send();
}

public function defaultForm(Schema $schema): Schema
{
return $schema
Expand Down Expand Up @@ -231,6 +294,96 @@ public function content(Schema $schema): Schema
->schema([
Livewire::make(ConnectionHub::class),
]),
Tab::make('Information')
->schema([
Section::make('Personal Information')
->description('Basic profile details and social links.')
->schema([
TextInput::make('informationData.name')
->label('Full Name')
->placeholder('Enter your full name')
->required(),

TextInput::make('informationData.nickname')
->label('Nickname')
->placeholder('How do you like to be called?'),

DatePicker::make('informationData.birthdate')
->label('Birthdate')
->placeholder('Select your birth date'),

Textarea::make('informationData.about')
->label('About')
->placeholder('Write a short description about yourself...')
->rows(4)
->columnSpanFull(),

TextInput::make('informationData.linkedin_url')
->label('LinkedIn URL')
->placeholder('https://linkedin.com/in/username')
->url(),

TextInput::make('informationData.github_url')
->label('GitHub URL')
->placeholder('https://github.com/username')
->url(),
])
->columns([
'sm' => 2,
'md' => 3,
])
->footerActions([
Action::make('saveInformation')
->label('Save Information')
->action(fn () => $this->saveInformation())
->color('primary')
->icon('heroicon-o-check'),
]),
]),

Tab::make('Address')
->schema([
Section::make('Address Information')
->description('Fill in your current address. The ZIP Code will automatically fetch your city and state.')
->schema([
SmartCep::make('addressData.zip_code')
->label('ZIP Code')
->placeholder('Enter your ZIP Code (e.g., 13000-000)')
->mask('99999-999')
->required()
->bindCityField('addressData.city')
->bindStateField('addressData.state')
->bindCountryField('addressData.country')
->live()
->columnSpan(1),

TextInput::make('addressData.country')
->label('Country')
->placeholder('Brazil')
->columnSpan(1),

TextInput::make('addressData.state')
->label('State')
->placeholder('São Paulo')
->columnSpan(1),

TextInput::make('addressData.city')
->label('City')
->placeholder('Campinas')
->columnSpan(1),
])
->columns([
'sm' => 2,
'md' => 4,
])
->footerActions([
Action::make('saveAddress')
->label('Save Address')
->action(fn () => $this->saveAddress())
->color('primary')
->icon('heroicon-o-map-pin'),
]),
]),
]),
...Arr::wrap($this->getMultiFactorAuthenticationContentComponent()),
]);
Expand Down Expand Up @@ -264,8 +417,10 @@ public function getMultiFactorAuthenticationContentComponent(): ?Component
->divided()
->secondary()
->schema(collect(Filament::getMultiFactorAuthenticationProviders())
->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider): int => $multiFactorAuthenticationProvider->isEnabled($user) ? 0 : 1)
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider): Component => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider
): int => $multiFactorAuthenticationProvider->isEnabled($user) ? 0 : 1)
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider
): Component => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->statePath($multiFactorAuthenticationProvider->getId()))
->all());
}
Expand Down Expand Up @@ -339,8 +494,10 @@ private function sendEmailChangeVerification(Model $record, string $newEmail): v

cache()->put($verificationSignature, true, ttl: now()->addHour());

$record->notify(app(NoticeOfEmailChangeRequest::class, [/** @phpstan-ignore-line */
'blockVerificationUrl' => Filament::getBlockEmailChangeVerificationUrl($record, $newEmail, $verificationSignature),
$record->notify(app(NoticeOfEmailChangeRequest::class, [
/** @phpstan-ignore-line */
'blockVerificationUrl' => Filament::getBlockEmailChangeVerificationUrl($record, $newEmail,
$verificationSignature),
'newEmail' => $newEmail,
]));

Expand Down Expand Up @@ -369,8 +526,10 @@ private function getEmailChangeVerificationSentNotification(string $newEmail): F
{
return FilamentNotification::make()
->success()
->title(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title', ['email' => $newEmail]))
->body(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body', ['email' => $newEmail]));
->title(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title',
['email' => $newEmail]))
->body(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body',
['email' => $newEmail]));
}

private function getSavedNotificationTitle(): ?string
Expand Down Expand Up @@ -443,7 +602,8 @@ private function getCurrentPasswordFormComponent(): Component
->currentPassword(guard: Filament::getAuthGuard())
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get): bool => filled($get('password')) || ($get('email') !== $this->getUser()->getAttributeValue('email')))
->visible(fn (Get $get
): bool => filled($get('password')) || ($get('email') !== $this->getUser()->getAttributeValue('email')))
->dehydrated(false);
}

Expand Down
126 changes: 126 additions & 0 deletions app-modules/user/tests/Feature/Filament/User/Pages/UserProfileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

use Filament\Facades\Filament;
use He4rt\Tenant\Models\Tenant;
use He4rt\User\Filament\User\Pages\UserProfile;
use He4rt\User\Models\User;

beforeEach(function (): void {
$this->user = User::factory()->create();
$this->tenant = Tenant::factory()->create([
'owner_id' => $this->user->id,
'slug' => 'he4rt',
]);

$this->actingAs($this->user);
Filament::setCurrentPanel('user');
Filament::setTenant($this->tenant);
});
it('renders the user profile page successfully for a tenant', function (): void {
Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->assertStatus(200)
->assertSee('Information')
->assertSee('Address');
});

it('saves user information correctly for tenant', function (): void {
$page = Livewire::test(UserProfile::class, ['tenant' => $this->tenant]);

$page->set('informationData', [
'name' => 'John Doe',
'nickname' => 'Johnny',
'birthdate' => '1995-03-14',
'about' => 'Software developer.',
'linkedin_url' => 'https://linkedin.com/in/johnny',
'github_url' => 'https://github.com/johnny',
]);

$page->call('saveInformation');

$this->assertDatabaseHas('user_information', [
'user_id' => $this->user->id,
'name' => 'John Doe',
'nickname' => 'Johnny',
]);
});

it('saves address correctly for tenant', function (): void {
$page = Livewire::test(UserProfile::class, ['tenant' => $this->tenant]);

$page->set('addressData', [
'zip_code' => '13000-000',
'country' => 'Brazil',
'state' => 'São Paulo',
'city' => 'Campinas',
]);

$page->call('saveAddress');

$this->assertDatabaseHas('user_address', [
'user_id' => $this->user->id,
'city' => 'Campinas',
'zip_code' => '13000-000',
]);
});

it('requires a name in information form', function (): void {
Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData', [
'name' => '',
'nickname' => 'Clint',
])
->call('saveInformation')
->assertHasErrors(['informationData.name' => 'required']);
});

it('validates nickname max length', function (): void {
$tooLong = str_repeat('N', 300);

Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData.nickname', $tooLong)
->call('saveInformation')
->assertHasErrors(['informationData.nickname' => 'max']);
});

it('validates linkedin and github urls', function (): void {
Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData.linkedin_url', 'not-a-url')
->set('informationData.github_url', '1234')
->call('saveInformation')
->assertHasErrors([
'informationData.linkedin_url' => 'url',
'informationData.github_url' => 'url',
]);
});

it('validates birthdate as a valid date', function (): void {
Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData.birthdate', 'not-a-date')
->call('saveInformation')
->assertHasErrors(['informationData.birthdate' => 'date']);
});

it('validates about field max 1000 characters', function (): void {
$tooLong = str_repeat('X', 1100);

Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData.about', $tooLong)
->call('saveInformation')
->assertHasErrors(['informationData.about' => 'max']);
});

it('saves valid information successfully', function (): void {
Livewire::test(UserProfile::class, ['tenant' => $this->tenant])
->set('informationData', [
'name' => 'John Doe',
'nickname' => 'JD',
'linkedin_url' => 'https://linkedin.com/in/johndoe',
'github_url' => 'https://github.com/johndoe',
'birthdate' => '1990-01-01',
'about' => 'Senior developer at Example Inc.',
])
->call('saveInformation')
->assertHasNoErrors();
});
Loading