From 3daa40570b4c14a1104af98d4a0b06ccafbe03a8 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 29 Sep 2026 21:21:43 +0200 Subject: [PATCH] Accept the option picked in selects of translatable records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Applications/Tables/ApplicationsTable.php | 2 + app/Filament/Resources/Calls/CallResource.php | 2 + .../Resources/Pages/Schemas/PageForm.php | 2 + .../Resources/Pages/Tables/PagesTable.php | 2 + .../Repositories/RepositoryResource.php | 2 + app/Filament/Resources/Talks/TalkResource.php | 2 + .../Testimonials/TestimonialResource.php | 3 + .../Support/TranslatedOptionLabel.php | 29 ++++++ .../TranslatedRelationshipSelectsTest.php | 94 +++++++++++++++++++ 9 files changed, 138 insertions(+) create mode 100644 app/Filament/Support/TranslatedOptionLabel.php create mode 100644 tests/Feature/Panel/TranslatedRelationshipSelectsTest.php diff --git a/app/Filament/Resources/Applications/Tables/ApplicationsTable.php b/app/Filament/Resources/Applications/Tables/ApplicationsTable.php index 7299c21..74dddcb 100644 --- a/app/Filament/Resources/Applications/Tables/ApplicationsTable.php +++ b/app/Filament/Resources/Applications/Tables/ApplicationsTable.php @@ -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(), diff --git a/app/Filament/Resources/Calls/CallResource.php b/app/Filament/Resources/Calls/CallResource.php index 00249ac..0abe7c1 100644 --- a/app/Filament/Resources/Calls/CallResource.php +++ b/app/Filament/Resources/Calls/CallResource.php @@ -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')), diff --git a/app/Filament/Resources/Pages/Schemas/PageForm.php b/app/Filament/Resources/Pages/Schemas/PageForm.php index 8eb56d4..d185143 100644 --- a/app/Filament/Resources/Pages/Schemas/PageForm.php +++ b/app/Filament/Resources/Pages/Schemas/PageForm.php @@ -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(), diff --git a/app/Filament/Resources/Pages/Tables/PagesTable.php b/app/Filament/Resources/Pages/Tables/PagesTable.php index 4d1c20a..f1ffe61 100644 --- a/app/Filament/Resources/Pages/Tables/PagesTable.php +++ b/app/Filament/Resources/Pages/Tables/PagesTable.php @@ -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(), diff --git a/app/Filament/Resources/Repositories/RepositoryResource.php b/app/Filament/Resources/Repositories/RepositoryResource.php index aabf03e..d548b78 100644 --- a/app/Filament/Resources/Repositories/RepositoryResource.php +++ b/app/Filament/Resources/Repositories/RepositoryResource.php @@ -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(), diff --git a/app/Filament/Resources/Talks/TalkResource.php b/app/Filament/Resources/Talks/TalkResource.php index 1e93c12..17023b0 100644 --- a/app/Filament/Resources/Talks/TalkResource.php +++ b/app/Filament/Resources/Talks/TalkResource.php @@ -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(), diff --git a/app/Filament/Resources/Testimonials/TestimonialResource.php b/app/Filament/Resources/Testimonials/TestimonialResource.php index 089bdf2..d9472da 100644 --- a/app/Filament/Resources/Testimonials/TestimonialResource.php +++ b/app/Filament/Resources/Testimonials/TestimonialResource.php @@ -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(), diff --git a/app/Filament/Support/TranslatedOptionLabel.php b/app/Filament/Support/TranslatedOptionLabel.php new file mode 100644 index 0000000..dbb1354 --- /dev/null +++ b/app/Filament/Support/TranslatedOptionLabel.php @@ -0,0 +1,29 @@ +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()); + } +} diff --git a/tests/Feature/Panel/TranslatedRelationshipSelectsTest.php b/tests/Feature/Panel/TranslatedRelationshipSelectsTest.php new file mode 100644 index 0000000..e842809 --- /dev/null +++ b/tests/Feature/Panel/TranslatedRelationshipSelectsTest.php @@ -0,0 +1,94 @@ +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 + */ + 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 + */ + #[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); + } +}