Files
landing-api/app/Filament/Resources/Calls/CallResource.php
Josep Piguillem 366568b793 Duplicate records as drafts, and preview them from the form
The copy keeps the content, stack and team, but is unpublished, out of any
review, and gets a title marked as a copy and a free slug. It replaces
Filament's replicate action, which left the copy without a slug and still
published. The same copy is available through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:14:51 +02:00

286 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\Calls;
use App\Enums\CallStatus;
use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Calls\Pages\CreateCall;
use App\Filament\Resources\Calls\Pages\EditCall;
use App\Filament\Resources\Calls\Pages\ListCalls;
use App\Filament\Resources\Calls\RelationManagers\ApplicationsRelationManager;
use App\Filament\Support\DuplicateAction;
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;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use UnitEnum;
/**
* Calls for contributors — the conversion point of the whole site.
*
* The hours-per-week and duration fields are what make this worth having as a
* model of its own: a volunteer weighs time, and "~4h/week for 8 weeks" is a
* decision someone can make in seconds.
*/
class CallResource extends Resource
{
protected static ?string $model = Call::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedHandRaised;
protected static ?int $navigationSort = 3;
public static function getModelLabel(): string
{
return __('panel.calls.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.calls.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.collective');
}
public static function getRecordTitle(?Model $record): ?string
{
return $record?->getTranslation('title', app()->getLocale());
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()->schema([
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
TextInput::make("title.{$locale}")
->label(__('panel.calls.fields.title'))
->required($isDefault)
->maxLength(255)
->columnSpanFull()
->live(onBlur: true)
->afterStateUpdated(function (?string $state, callable $get, callable $set) use ($locale) {
if (blank($get("slug.{$locale}"))) {
$set("slug.{$locale}", Str::slug((string) $state));
}
}),
TextInput::make("slug.{$locale}")
->label(__('panel.calls.fields.slug'))
->required($isDefault)
->maxLength(255)
->columnSpanFull()
->rules(fn ($record) => [new UniqueTranslatedSlug(Call::class, $record?->id)]),
Textarea::make("description.{$locale}")
->label(__('panel.calls.fields.description'))
->rows(4)
->columnSpanFull(),
// Unpaid work needs an explicit exchange. Saying it out
// loud attracts more people than leaving it implied.
Textarea::make("what_you_get.{$locale}")
->label(__('panel.calls.fields.what_you_get'))
->helperText(__('panel.calls.fields.what_you_get_hint'))
->rows(3)
->columnSpanFull(),
]),
]),
Section::make(__('panel.calls.sections.commitment'))
->description(__('panel.calls.sections.commitment_hint'))
->columns(3)
->schema([
TextInput::make('hours_per_week')
->label(__('panel.calls.fields.hours_per_week'))
->numeric()
->minValue(1)
->maxValue(40)
->suffix('h'),
TextInput::make('duration_weeks')
->label(__('panel.calls.fields.duration_weeks'))
->numeric()
->minValue(1),
Select::make('seniority')
->label(__('panel.calls.fields.seniority'))
->options([
'any' => __('panel.calls.seniority.any'),
'junior' => __('panel.calls.seniority.junior'),
'mid' => __('panel.calls.seniority.mid'),
'senior' => __('panel.calls.seniority.senior'),
]),
]),
Section::make()
->columns(2)
->schema([
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')),
Select::make('status')
->label(__('panel.calls.fields.status'))
->options(CallStatus::class)
->default(CallStatus::Open)
->required(),
Select::make('technologies')
->label(__('panel.calls.fields.technologies'))
->relationship('technologies', 'name')
->multiple()
->preload()
->searchable()
->columnSpanFull(),
DatePicker::make('opens_at')->label(__('panel.calls.fields.opens_at')),
DatePicker::make('closes_at')->label(__('panel.calls.fields.closes_at')),
]),
]);
}
public static function table(Table $table): Table
{
$locale = app()->getLocale();
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('title')
->label(__('panel.calls.fields.title'))
->weight('medium')
->wrap()
->searchable(query: fn ($q, string $search) => $q
->where("title->{$locale}", 'like', "%{$search}%")),
TextColumn::make('status')
->label(__('panel.calls.fields.status'))
->badge()
->sortable(),
TextColumn::make('project.title')
->label(__('panel.calls.fields.project'))
->badge()
->color('gray')
->placeholder(__('panel.calls.no_project')),
// The number that does the work on the public site.
TextColumn::make('hours_per_week')
->label(__('panel.calls.fields.commitment'))
->formatStateUsing(fn ($state, Call $record) => $state
? trans_choice('panel.calls.commitment_summary', $record->duration_weeks ?? 0, [
'hours' => $state,
'weeks' => $record->duration_weeks,
])
: '—')
->badge()
->color('info'),
TextColumn::make('technologies.name')
->label(__('panel.calls.fields.technologies'))
->badge()
->color('gray')
->limitList(3)
->toggleable(),
TextColumn::make('closes_at')
->label(__('panel.calls.fields.closes_at'))
->date()
->placeholder('—')
->sortable(),
])
->filters([
SelectFilter::make('status')
->label(__('panel.calls.fields.status'))
->options(CallStatus::options())
->multiple(),
SelectFilter::make('technologies')
->label(__('panel.calls.fields.technologies'))
->relationship('technologies', 'name')
->multiple()
->preload(),
])
->recordActions([
PreviewAction::for('call'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([DeleteBulkAction::make()]),
]);
}
public static function getRelations(): array
{
return [
RevisionsRelationManager::class,
ApplicationsRelationManager::class,
];
}
/**
* @return array<int, string>
*/
public static function getGloballySearchableAttributes(): array
{
return ['title->'.app()->getLocale()];
}
public static function getPages(): array
{
return [
'index' => ListCalls::route('/'),
'create' => CreateCall::route('/create'),
'edit' => EditCall::route('/{record}/edit'),
];
}
/**
* How many contributor slots are currently open — the number that tells
* the collective whether the site is doing its job.
*/
public static function getNavigationBadge(): ?string
{
$open = static::getModel()::query()->open()->count();
return $open > 0 ? (string) $open : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'success';
}
}