Accept the option picked in selects of translatable records
Saving a page with a category failed with "El campo categoría no está
en la lista de valores permitidos" — for every category, not only the
one being tried. Filament builds such a list through a query, which
handles `->relationship('category', 'name->es')`, but validates a choice
by reading the title back off the record as a property,
`$record->{'name->es'}`, which does not exist. No label, so every
option was refused.
The same pattern was in seven more places: a call's, a testimonial's,
a talk's and a repository's project, a testimonial's service, and the
page and application filters. TranslatedOptionLabel gives Filament the
record's own translation instead, in the language of whoever uses the
panel; the JSON path stays for search and ordering.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ namespace App\Filament\Resources\Applications\Tables;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Application;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@@ -93,6 +94,7 @@ class ApplicationsTable
|
||||
SelectFilter::make('call')
|
||||
->label(__('panel.applications.fields.call'))
|
||||
->relationship('call', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Filament\Resources\Calls\Pages\ListCalls;
|
||||
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Call;
|
||||
use App\Rules\UniqueTranslatedSlug;
|
||||
use BackedEnum;
|
||||
@@ -141,6 +142,7 @@ class CallResource extends Resource
|
||||
Select::make('project_id')
|
||||
->label(__('panel.calls.fields.project'))
|
||||
->relationship('project', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText(__('panel.calls.fields.project_hint')),
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Filament\Resources\Pages\Schemas;
|
||||
use App\Enums\PageStatus;
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Page;
|
||||
use App\Rules\UniqueTranslatedSlug;
|
||||
use App\Support\DuplicateTitles;
|
||||
@@ -112,6 +113,7 @@ class PageForm
|
||||
Select::make('category_id')
|
||||
->label(__('panel.pages.fields.category'))
|
||||
->relationship('category', 'name->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Enums\PageStatus;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\ReviewActions;
|
||||
use App\Filament\Support\ReviewStatusColumn;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Filament\Support\TranslationStatusColumn;
|
||||
use App\Filament\Support\ViewsColumn;
|
||||
use App\Models\Page;
|
||||
@@ -127,6 +128,7 @@ class PagesTable
|
||||
SelectFilter::make('category')
|
||||
->label(__('panel.pages.fields.category'))
|
||||
->relationship('category', 'name->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Repositories\Pages\CreateRepository;
|
||||
use App\Filament\Resources\Repositories\Pages\EditRepository;
|
||||
use App\Filament\Resources\Repositories\Pages\ListRepositories;
|
||||
use App\Filament\Resources\Repositories\Tables\RepositoriesTable;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Repository;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -81,6 +82,7 @@ class RepositoryResource extends Resource
|
||||
Select::make('project_id')
|
||||
->label(__('panel.repositories.fields.project'))
|
||||
->relationship('project', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Talks\Pages\CreateTalk;
|
||||
use App\Filament\Resources\Talks\Pages\EditTalk;
|
||||
use App\Filament\Resources\Talks\Pages\ListTalks;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Talk;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -126,6 +127,7 @@ class TalkResource extends Resource
|
||||
Select::make('project_id')
|
||||
->label(__('panel.talks.fields.project'))
|
||||
->relationship('project', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
||||
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
||||
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
use App\Filament\Support\TranslatedOptionLabel;
|
||||
use App\Models\Testimonial;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -137,12 +138,14 @@ class TestimonialResource extends Resource
|
||||
Select::make('project_id')
|
||||
->label(__('panel.testimonials.fields.project'))
|
||||
->relationship('project', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
Select::make('service_id')
|
||||
->label(__('panel.testimonials.fields.service'))
|
||||
->relationship('service', 'title->'.config('locales.default'))
|
||||
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
|
||||
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
29
app/Filament/Support/TranslatedOptionLabel.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* The label of a relationship option whose title is translatable.
|
||||
*
|
||||
* `->relationship('category', 'name->es')` builds the list fine — that path
|
||||
* goes through a query — but to validate a choice Filament reads the title
|
||||
* back off the record as a property, `$record->{'name->es'}`, which does not
|
||||
* exist. Every choice then came back without a label and was refused as "not
|
||||
* in the list of allowed values", whichever one it was. Given this, Filament
|
||||
* asks the record instead, in the language of whoever is using the panel.
|
||||
*
|
||||
* The JSON path stays in `relationship()`: it is still what the search box
|
||||
* queries and the list is ordered by.
|
||||
*/
|
||||
final class TranslatedOptionLabel
|
||||
{
|
||||
public static function using(string $attribute): Closure
|
||||
{
|
||||
return fn (Model $record): ?string => $record->getTranslation($attribute, app()->getLocale());
|
||||
}
|
||||
}
|
||||
94
tests/Feature/Panel/TranslatedRelationshipSelectsTest.php
Normal file
94
tests/Feature/Panel/TranslatedRelationshipSelectsTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Calls\Pages\CreateCall;
|
||||
use App\Filament\Resources\Pages\Pages\CreatePage;
|
||||
use App\Filament\Resources\Repositories\Pages\CreateRepository;
|
||||
use App\Filament\Resources\Talks\Pages\CreateTalk;
|
||||
use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
||||
use App\Models\Category;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* A select whose options are records with a translatable title — a page's
|
||||
* category, the project a call belongs to — must accept the option picked.
|
||||
*
|
||||
* It did not: every choice was refused as "not in the list of allowed
|
||||
* values", because Filament validated it by reading `name->es` off the record
|
||||
* as a property. See App\Filament\Support\TranslatedOptionLabel.
|
||||
*/
|
||||
class TranslatedRelationshipSelectsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: class-string, 1: string, 2: string}>
|
||||
*/
|
||||
public static function selects(): array
|
||||
{
|
||||
return [
|
||||
"a page's category" => [CreatePage::class, 'category_id', Category::class],
|
||||
"a call's project" => [CreateCall::class, 'project_id', Project::class],
|
||||
"a testimonial's project" => [CreateTestimonial::class, 'project_id', Project::class],
|
||||
"a testimonial's service" => [CreateTestimonial::class, 'service_id', Service::class],
|
||||
"a talk's project" => [CreateTalk::class, 'project_id', Project::class],
|
||||
"a repository's project" => [CreateRepository::class, 'project_id', Project::class],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string $page
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('selects')]
|
||||
public function the_option_picked_is_accepted(string $page, string $field, string $model): void
|
||||
{
|
||||
$record = $model::factory()->create();
|
||||
|
||||
// Only this field is asserted: the rest of each form is left empty on
|
||||
// purpose, and its own errors are not what this is about.
|
||||
Livewire::test($page)
|
||||
->fillForm([$field => $record->getKey()])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors([$field]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_option_reads_in_the_language_of_whoever_uses_the_panel(): void
|
||||
{
|
||||
$category = Category::factory()->create(['name' => ['es' => 'Novedades', 'en' => 'News']]);
|
||||
|
||||
app()->setLocale('en');
|
||||
|
||||
$select = collect(Livewire::test(CreatePage::class)->instance()->form->getFlatComponents())
|
||||
->first(fn ($component) => method_exists($component, 'getName') && $component->getName() === 'category_id');
|
||||
|
||||
$label = $select->getOptionLabelFromRecord($category);
|
||||
|
||||
$this->assertSame('News', $label);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user