Files
landing-api/app/Filament/Resources/Applications/Tables/ApplicationsTable.php
Josep Piguillem 3daa40570b
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
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>
2026-09-29 21:21:43 +02:00

166 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
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;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
/**
* An inbox rather than a catalogue.
*
* What it optimises for is the one question worth asking every morning: is
* anybody waiting for an answer? Hence the pending filter on by default, the
* relative timestamps, and the status transitions one click from the list.
*/
class ApplicationsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('name')
->label(__('panel.applications.fields.name'))
->weight('medium')
->description(fn (Application $record) => $record->email)
->searchable(['name', 'email']),
TextColumn::make('kind')
->label(__('panel.applications.fields.kind'))
->badge()
->sortable(),
TextColumn::make('call_title')
->label(__('panel.applications.fields.subject'))
->state(fn (Application $record) => $record->subject())
->color(fn (Application $record) => $record->call_id ? null : 'gray')
->wrap()
->toggleable(),
TextColumn::make('hours_per_week')
->label(__('panel.applications.fields.hours_per_week'))
->badge()
->color('info')
->formatStateUsing(fn (?int $state) => $state ? "{$state} h" : '—')
->toggleable(),
TextColumn::make('status')
->label(__('panel.applications.fields.status'))
->badge()
->sortable(),
TextColumn::make('created_at')
->label(__('panel.applications.fields.received_at'))
->since()
->dateTimeTooltip()
->sortable(),
TextColumn::make('handler.name')
->label(__('panel.applications.fields.handled_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
// No default here: the tabs above already decide what is in
// view, and a filter with a default would silently empty the
// spam tab.
SelectFilter::make('status')
->label(__('panel.applications.fields.status'))
->options(ApplicationStatus::options())
->multiple(),
SelectFilter::make('kind')
->label(__('panel.applications.fields.kind'))
->options(ApplicationKind::options()),
SelectFilter::make('call')
->label(__('panel.applications.fields.call'))
->relationship('call', 'title->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('title'))
->searchable()
->preload(),
TrashedFilter::make(),
])
->recordActions([
ViewAction::make(),
ActionGroup::make(static::statusActions())
->label(__('panel.applications.actions.group'))
->icon('heroicon-m-ellipsis-horizontal'),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('mark_spam')
->label(__('panel.applications.actions.spam'))
->icon('heroicon-o-no-symbol')
->color('danger')
->requiresConfirmation()
->authorize(fn () => (bool) Auth::user()?->can('applications.update'))
->action(fn (Collection $records) => $records->each(
fn (Application $record) => $record->markAs(ApplicationStatus::Spam, Auth::user()),
))
->deselectRecordsAfterCompletion(),
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
])
->emptyStateHeading(__('panel.applications.empty.heading'))
->emptyStateDescription(__('panel.applications.empty.description'))
->emptyStateIcon('heroicon-o-inbox');
}
/**
* The status transitions, shared by the list and the record page so the
* two cannot drift apart.
*
* @return array<int, Action>
*/
public static function statusActions(): array
{
$styles = [
ApplicationStatus::Contacted->value => ['heroicon-o-chat-bubble-left-right', 'info'],
ApplicationStatus::Accepted->value => ['heroicon-o-check-circle', 'success'],
ApplicationStatus::Declined->value => ['heroicon-o-x-circle', 'gray'],
ApplicationStatus::Spam->value => ['heroicon-o-no-symbol', 'danger'],
];
$actions = [];
foreach ($styles as $status => [$icon, $color]) {
$target = ApplicationStatus::from($status);
$actions[] = Action::make("mark_{$status}")
->label(__("panel.applications.actions.{$status}"))
->icon($icon)
->color($color)
->requiresConfirmation($target === ApplicationStatus::Spam)
->authorize('update')
->visible(fn (Application $record) => $record->status !== $target)
->action(fn (Application $record) => $record->markAs($target, Auth::user()));
}
return $actions;
}
}