feat(users): idioma por defecto con banderas SVG + conmutador coherente
- Campo "Idioma por defecto" al crear/editar usuario (columna locale ya existente),
como desplegable Alpine con banderas SVG reales (no emoji, que en Windows se ven
como "ES"/"GB") servidas localmente: public/images/flags/{es,gb}.svg.
- User: locale añadido a fillable. UserForm: propiedad/validación/guardado de locale.
- LanguageSwitcher de la cabecera usa las mismas banderas SVG.
- Regla CSS [x-cloak] en el layout para evitar parpadeo de desplegables Alpine.
Tests: UserLocaleTest (2) — crear/editar persisten el idioma.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,9 @@ class UserForm extends Component
|
|||||||
// Permisos
|
// Permisos
|
||||||
public string $formRole = '';
|
public string $formRole = '';
|
||||||
|
|
||||||
|
// Preferencias
|
||||||
|
public string $locale = 'es';
|
||||||
|
|
||||||
// Notas
|
// Notas
|
||||||
public string $notes = '';
|
public string $notes = '';
|
||||||
|
|
||||||
@@ -43,6 +46,12 @@ class UserForm extends Component
|
|||||||
public $roles;
|
public $roles;
|
||||||
public $companies;
|
public $companies;
|
||||||
|
|
||||||
|
/** Idiomas disponibles (código => nombre + archivo de bandera). */
|
||||||
|
public array $languages = [
|
||||||
|
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
|
||||||
|
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
|
||||||
|
];
|
||||||
|
|
||||||
public function mount(?User $user = null): void
|
public function mount(?User $user = null): void
|
||||||
{
|
{
|
||||||
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
|
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
|
||||||
@@ -65,6 +74,7 @@ class UserForm extends Component
|
|||||||
$this->email = $user->email;
|
$this->email = $user->email;
|
||||||
$this->notes = $user->notes ?? '';
|
$this->notes = $user->notes ?? '';
|
||||||
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
|
$this->formRole = $user->roles->first()?->name ?? $this->formRole;
|
||||||
|
$this->locale = $user->locale ?? $this->locale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +93,7 @@ class UserForm extends Component
|
|||||||
'phone' => 'nullable|string|max:30',
|
'phone' => 'nullable|string|max:30',
|
||||||
'email' => "required|email|max:255|unique:users,email,{$id}",
|
'email' => "required|email|max:255|unique:users,email,{$id}",
|
||||||
'formRole' => 'required|exists:roles,name',
|
'formRole' => 'required|exists:roles,name',
|
||||||
|
'locale' => 'required|in:' . implode(',', array_keys($this->languages)),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!$this->user) {
|
if (!$this->user) {
|
||||||
@@ -103,6 +114,7 @@ class UserForm extends Component
|
|||||||
'companyId' => 'empresa',
|
'companyId' => 'empresa',
|
||||||
'formPassword'=> 'contraseña',
|
'formPassword'=> 'contraseña',
|
||||||
'formRole' => 'rol',
|
'formRole' => 'rol',
|
||||||
|
'locale' => 'idioma',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function copyCompanyAddress(): void
|
public function copyCompanyAddress(): void
|
||||||
@@ -139,6 +151,7 @@ class UserForm extends Component
|
|||||||
'phone' => $this->phone ?: null,
|
'phone' => $this->phone ?: null,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'notes' => $this->notes ?: null,
|
'notes' => $this->notes ?: null,
|
||||||
|
'locale' => $this->locale,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->formPassword !== '') {
|
if ($this->formPassword !== '') {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class User extends Authenticatable
|
|||||||
'email', 'password',
|
'email', 'password',
|
||||||
'status', 'valid_from', 'valid_until',
|
'status', 'valid_from', 'valid_until',
|
||||||
'company_id', 'phone', 'address', 'notes',
|
'company_id', 'phone', 'address', 'notes',
|
||||||
|
'locale',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
|
||||||
|
<rect width="750" height="500" fill="#AA151B"/>
|
||||||
|
<rect y="125" width="750" height="250" fill="#F1BF00"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 178 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30">
|
||||||
|
<clipPath id="gb-t">
|
||||||
|
<path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/>
|
||||||
|
</clipPath>
|
||||||
|
<rect width="60" height="30" fill="#012169"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" clip-path="url(#gb-t)" stroke="#C8102E" stroke-width="4"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#fff" stroke-width="10"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" stroke-width="6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 518 B |
@@ -15,6 +15,9 @@
|
|||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|
||||||
|
{{-- Evita el parpadeo de elementos Alpine (x-cloak) antes de inicializar --}}
|
||||||
|
<style>[x-cloak]{display:none !important;}</style>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
crossorigin=""/>
|
crossorigin=""/>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<div class="flex items-center gap-1"
|
<div class="flex items-center gap-1"
|
||||||
x-on:locale-changed.window="window.location.reload()">
|
x-on:locale-changed.window="window.location.reload()">
|
||||||
@foreach(['en' => '🇬🇧 EN', 'es' => '🇪🇸 ES'] as $code => $label)
|
@foreach(['es' => ['flag' => 'es.svg', 'label' => 'ES'], 'en' => ['flag' => 'gb.svg', 'label' => 'EN']] as $code => $lang)
|
||||||
<button wire:click="switchLanguage('{{ $code }}')"
|
<button wire:click="switchLanguage('{{ $code }}')"
|
||||||
class="btn btn-xs {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
|
class="btn btn-xs gap-1.5 {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
|
||||||
title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}">
|
title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}">
|
||||||
{{ $label }}
|
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
|
||||||
|
class="w-4 h-auto rounded-sm border border-base-300" alt="{{ $lang['label'] }}">
|
||||||
|
{{ $lang['label'] }}
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -291,6 +291,46 @@
|
|||||||
@error('formRole') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
@error('formRole') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-4 mt-4">
|
||||||
|
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
|
||||||
|
Idioma por defecto <span class="text-error">*</span>
|
||||||
|
<p class="text-xs text-gray-400 font-normal mt-0.5">Idioma de la interfaz para este usuario</p>
|
||||||
|
</label>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div x-data="{ open: false }" @click.outside="open = false" class="relative w-full max-w-xs">
|
||||||
|
{{-- Trigger: muestra la bandera + nombre del idioma seleccionado --}}
|
||||||
|
<button type="button" @click="open = !open"
|
||||||
|
class="select select-bordered w-full flex items-center gap-2 text-left">
|
||||||
|
@foreach($languages as $code => $lang)
|
||||||
|
<span x-show="$wire.locale === '{{ $code }}'" class="flex items-center gap-2">
|
||||||
|
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
|
||||||
|
class="w-5 h-auto rounded-sm border border-base-300" alt="{{ $lang['name'] }}">
|
||||||
|
{{ $lang['name'] }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- Opciones --}}
|
||||||
|
<ul x-show="open" x-transition x-cloak
|
||||||
|
class="absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-box shadow-lg p-1">
|
||||||
|
@foreach($languages as $code => $lang)
|
||||||
|
<li>
|
||||||
|
<button type="button"
|
||||||
|
@click="$wire.set('locale', '{{ $code }}'); open = false"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-base-200 text-left"
|
||||||
|
:class="$wire.locale === '{{ $code }}' ? 'bg-base-200 font-medium' : ''">
|
||||||
|
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
|
||||||
|
class="w-5 h-auto rounded-sm border border-base-300" alt="{{ $lang['name'] }}">
|
||||||
|
{{ $lang['name'] }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@error('locale') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ══════════════════════════════════════════════════════════
|
{{-- ══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\UserForm;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserLocaleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function admin(): User
|
||||||
|
{
|
||||||
|
foreach (['create users', 'edit users'] as $p) {
|
||||||
|
Permission::findOrCreate($p);
|
||||||
|
}
|
||||||
|
Role::findOrCreate('Tecnico');
|
||||||
|
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->givePermissionTo(['create users', 'edit users']);
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function company(): Company
|
||||||
|
{
|
||||||
|
return Company::create(['name' => 'ACME', 'estado' => 'activo', 'type' => 'constructor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creating_a_user_persists_the_selected_locale(): void
|
||||||
|
{
|
||||||
|
$admin = $this->admin();
|
||||||
|
$company = $this->company();
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(UserForm::class)
|
||||||
|
->set('firstName', 'Ada')
|
||||||
|
->set('lastName', 'Lovelace')
|
||||||
|
->set('email', 'ada@example.com')
|
||||||
|
->set('companyId', $company->id)
|
||||||
|
->set('formRole', 'Tecnico')
|
||||||
|
->set('formPassword', 'Password123')
|
||||||
|
->set('locale', 'en')
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('users', ['email' => 'ada@example.com', 'locale' => 'en']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_editing_a_user_loads_and_updates_the_locale(): void
|
||||||
|
{
|
||||||
|
$admin = $this->admin();
|
||||||
|
$company = $this->company();
|
||||||
|
$target = User::factory()->create([
|
||||||
|
'locale' => 'es',
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'first_name' => 'Bob',
|
||||||
|
'last_name' => 'Stone',
|
||||||
|
]);
|
||||||
|
$target->assignRole('Tecnico');
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test(UserForm::class, ['user' => $target])
|
||||||
|
->assertSet('locale', 'es')
|
||||||
|
->set('locale', 'en')
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
$this->assertEquals('en', $target->fresh()->locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user