Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions app-modules/panel-app/lang/en/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
'notifications' => [
'saved' => 'Profile saved successfully!',
'no_profile' => 'Profile not found for this tenant.',
'validation_error' => 'We could not save your profile.',
],
];
1 change: 1 addition & 0 deletions app-modules/panel-app/lang/pt_BR/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
'notifications' => [
'saved' => 'Perfil salvo com sucesso!',
'no_profile' => 'Perfil não encontrado para este tenant.',
'validation_error' => 'Não foi possível salvar seu perfil.',
],
];
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,15 @@ class="absolute inset-0 z-30 flex items-center justify-center rounded-full bg-bl
<input
id="nickname"
type="text"
wire:model.blur="data.nickname"
wire:model.blur="nicknameInput"
x-on:scroll-to-nickname.window="$el.scrollIntoView({ behavior: 'smooth', block: 'center' }); $el.focus()"
placeholder="{{ __('panel-app::profile.placeholders.nickname') }}"
maxlength="100"
class="fi-input block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition-colors focus:border-purple-500 focus:ring-1 focus:ring-purple-500 dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus:border-purple-500"
class="fi-input block w-full rounded-lg border {{ $errors->has('nickname') ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-purple-500 focus:ring-purple-500' }} bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition-colors focus:ring-1 dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
@error('nickname')
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<label for="birthdate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Expand All @@ -146,4 +150,4 @@ class="fi-input block w-full rounded-lg border border-gray-300 bg-white px-3 py-
</div>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
])

@php
$name = $user?->name ?? '';
$username = $user?->username ?? '';
$nickname = $data['nickname'] ?? null;
$name = ($nickname ?: null) ?? $user?->name ?? $username;
$headline = $data['headline'] ?? null;
$about = $data['about'] ?? null;
$yearsExperience = $data['years_experience'] ?? null;
Expand Down Expand Up @@ -266,4 +266,4 @@ class="inline-block rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5
<div class="border-t border-gray-100 px-6 py-3 dark:border-white/5">
<p class="text-center text-xs text-gray-400 dark:text-gray-500">Esse card aparece na listagem de membros e no seu perfil público.</p>
</div>
</div>
</div>
91 changes: 76 additions & 15 deletions app-modules/panel-app/src/Pages/ProfilePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use He4rt\Profile\Models\Profile;
use He4rt\Profile\Models\Skill;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
Expand All @@ -61,6 +62,8 @@ class ProfilePage extends Page
#[Validate(rule: 'nullable|image|mimes:jpg,jpeg,png,webp|max:4096')]
public $coverUpload;

public ?string $nicknameInput = null;

protected static string|null|BackedEnum $navigationIcon = 'heroicon-o-user-circle';

protected static ?string $title = 'Profile';
Expand Down Expand Up @@ -98,6 +101,8 @@ public function mount(): void
$profile->preferences->employmentTypes,
),
]);

$this->nicknameInput = $profile->nickname;
}

public function form(Schema $schema): Schema
Expand Down Expand Up @@ -377,13 +382,14 @@ public function form(Schema $schema): Schema

public function save(): void
{
$this->resetErrorBag();

$formData = $this->form->getState();
$profile = $this->getRecord();

$socialLinks = $this->repeaterToSocialLinks($formData['social_links'] ?? []);

$dto = UpsertProfileDTO::fromArray([
'nickname' => $this->data['nickname'] ?? null,
'nickname' => mb_trim($this->nicknameInput ?? ''),
'birthdate' => $this->data['birthdate'] ?? null,
'about' => $formData['about'] ?? null,
'headline' => $formData['headline'] ?? null,
Expand All @@ -400,23 +406,31 @@ public function save(): void
],
]);

resolve(UpsertProfile::class)->handle($profile, $dto);
try {
resolve(UpsertProfile::class)->handle($profile, $dto);

$available = (bool) ($formData['available_for_proposals'] ?? false);
$rawStartAvailability = $formData['start_availability'] ?? null;
$startAvailability = match (true) {
$rawStartAvailability instanceof StartAvailability => $rawStartAvailability,
is_string($rawStartAvailability) => StartAvailability::from($rawStartAvailability),
$available => StartAvailability::Negotiable,
default => null,
};
$available = (bool) ($formData['available_for_proposals'] ?? false);
$rawStartAvailability = $formData['start_availability'] ?? null;
$startAvailability = match (true) {
$rawStartAvailability instanceof StartAvailability => $rawStartAvailability,
is_string($rawStartAvailability) => StartAvailability::from($rawStartAvailability),
$available => StartAvailability::Negotiable,
default => null,
};

resolve(ToggleAvailability::class)->handle($profile, $available, $startAvailability);
resolve(ToggleAvailability::class)->handle($profile, $available, $startAvailability);

resolve(SyncProfileSkills::class)->handle($profile, $this->repeaterToSkills($formData['skills'] ?? []));
resolve(SyncProfileSkills::class)->handle($profile, $this->repeaterToSkills($formData['skills'] ?? []));

$this->saveMedia();
$this->form->saveRelationships();
$this->saveMedia();
$this->form->saveRelationships();
} catch (ValidationException $validationException) {
$this->surfaceValidationErrors($validationException);

return;
}

$this->data['nickname'] = mb_trim($this->nicknameInput ?? '') ?: null;

Notification::make()
->success()
Expand Down Expand Up @@ -493,6 +507,53 @@ public function removeCover(): void
auth()->user()->clearMediaCollection('cover');
}

/**
* Routes each domain validation error to its input: nickname has a dedicated
* field outside the Filament form, Filament fields get an inline error, and
* keys with no rendered input (e.g. birthdate) fall back to a danger toast.
*/
private function surfaceValidationErrors(ValidationException $exception): void
{
$toastMessages = [];

foreach ($exception->errors() as $field => $messages) {
$fieldMessages = array_map(static fn (mixed $message): string => (string) $message, (array) $messages);

if ($field === 'nickname') {
foreach ($fieldMessages as $message) {
$this->addError('nickname', $message);
}

$this->dispatch('scroll-to-nickname');

continue;
}

$statePath = 'data.'.$field;
$hasField = $this->form->getComponentByStatePath($statePath, withAbsoluteStatePath: true) !== null;

if ($hasField) {
foreach ($fieldMessages as $message) {
$this->addError($statePath, $message);
}

continue;
}

$toastMessages = [...$toastMessages, ...$fieldMessages];
}

if ($toastMessages === []) {
return;
}

Notification::make()
->danger()
->title(__('panel-app::profile.notifications.validation_error'))
->body(implode(' ', $toastMessages))
->send();
}

private function saveMedia(): void
{
/** @var User $user */
Expand Down
46 changes: 45 additions & 1 deletion app-modules/panel-app/tests/Feature/ProfilePageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@

test('profile page saves all fields', function (): void {
livewire(ProfilePage::class)
->set('data.nickname', 'Dan')
->set('nicknameInput', 'Dan')
->fillForm([
'headline' => 'Backend Developer',
'seniority_level' => 'mid',
Expand Down Expand Up @@ -194,6 +194,50 @@
->assertHasFormErrors(['headline']);
});

test('profile page shows domain validation inline when the field exists', function (): void {
// salary min > max only fails at the domain layer (no mirrored Filament rule),
// but expected_salary_max is a real field, so the error must land inline on it.
livewire(ProfilePage::class)
->fillForm([
'available_for_proposals' => true,
'expected_salary_min' => 9_000,
'expected_salary_max' => 5_000,
])
->call('save')
->assertHasFormErrors(['expected_salary_max']);

$this->profile->refresh();

expect($this->profile->expected_salary_min)->toBeNull()
->and($this->profile->expected_salary_max)->toBeNull();
});

test('profile page shows nickname validation inline on its dedicated input', function (): void {
// nickname lives in its own input (nicknameInput) outside the Filament form,
// so its domain error must land inline under the 'nickname' key, not a toast.
livewire(ProfilePage::class)
->set('nicknameInput', str_repeat('a', 101))
->call('save')
->assertHasErrors(['nickname']);

$this->profile->refresh();

expect($this->profile->nickname)->toBeNull();
});

test('profile page falls back to a danger toast for fields with no rendered input', function (): void {
// birthdate is persisted from $this->data and has no form field to attach to,
// so its domain error (a future date fails beforeToday) can only surface as a toast.
livewire(ProfilePage::class)
->set('data.birthdate', '2999-12-31')
->call('save')
->assertNotified(__('panel-app::profile.notifications.validation_error'));

$this->profile->refresh();

expect($this->profile->birthdate)->toBeNull();
});

test('profile page does not show account fields', function (): void {
livewire(ProfilePage::class)
->assertFormFieldDoesNotExist('email')
Expand Down
Loading