Files
landing-api/app/Filament/Resources/Services/ServiceResource.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

400 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\Services;
use App\Filament\RelationManagers\ReviewsRelationManager;
use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Services\Pages\CreateService;
use App\Filament\Resources\Services\Pages\EditService;
use App\Filament\Resources\Services\Pages\ListServices;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
use App\Filament\Support\TranslatableTabs;
use App\Filament\Support\TranslationStatusColumn;
use App\Filament\Support\ViewsColumn;
use App\Models\Service;
use App\Rules\UniqueTranslatedSlug;
use App\Support\DuplicateTitles;
use App\Support\TranslationFreshness;
use BackedEnum;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum;
/**
* What the collective offers to do.
*
* The counterpart to a project: one says what has been built, this says what
* somebody would be asking for. The relation to projects is what keeps it
* honest — a service that cannot point at real work is a promise.
*/
class ServiceResource extends Resource
{
protected static ?string $model = Service::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
protected static ?int $navigationSort = 1;
public static function getModelLabel(): string
{
return __('panel.services.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.services.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.services.fields.title'))
->required($isDefault)
->maxLength(255)
->columnSpanFull()
->live(onBlur: true)
->afterStateUpdated(function (?string $state, ?Model $record, callable $get, callable $set) use ($locale, $isDefault) {
if (blank($get("slug.{$locale}"))) {
$set("slug.{$locale}", Str::slug((string) $state));
}
if ($isDefault) {
DuplicateTitles::warnIfSimilar(
Service::class,
ServiceResource::class,
$state,
$locale,
$record?->getKey(),
);
}
}),
TextInput::make("slug.{$locale}")
->label(__('panel.services.fields.slug'))
->required($isDefault)
->maxLength(255)
->columnSpanFull()
->rules(fn ($record) => [new UniqueTranslatedSlug(Service::class, $record?->id)]),
Textarea::make("tagline.{$locale}")
->label(__('panel.services.fields.tagline'))
->helperText(__('panel.services.fields.tagline_hint'))
->rows(2)
->maxLength(255)
->columnSpanFull(),
Textarea::make("description.{$locale}")
->label(__('panel.services.fields.description'))
->rows(6)
->columnSpanFull(),
]),
]),
Section::make(__('panel.services.sections.evidence'))
->description(__('panel.services.sections.evidence_hint'))
->schema([
Select::make('projects')
->label(__('panel.services.fields.projects'))
// Explicit order: Filament would otherwise sort a
// `select distinct` by a JSON path, which Postgres
// rejects.
->relationship(
name: 'projects',
titleAttribute: 'title->'.config('locales.default'),
modifyQueryUsing: fn (Builder $query) => $query->orderByDesc('published_at'),
)
->multiple()
->preload()
->searchable()
->columnSpanFull(),
Select::make('technologies')
->label(__('panel.services.fields.technologies'))
->relationship('technologies', 'name')
->multiple()
->preload()
->searchable()
->columnSpanFull(),
]),
Section::make()
->columns(2)
->schema([
TextInput::make('icon')
->label(__('panel.services.fields.icon'))
->helperText(__('panel.services.fields.icon_hint'))
->placeholder('heroicon-o-code-bracket')
->maxLength(120),
TextInput::make('position')
->label(__('panel.services.fields.position'))
->numeric()
->default(0),
Toggle::make('is_published')
->label(__('panel.services.fields.is_published'))
// Writing the offer and putting it on the website are
// separate permissions, exactly as with projects.
->disabled(fn () => Auth::user()?->cannot('services.publish'))
->dehydrated(),
Toggle::make('is_featured')
->label(__('panel.services.fields.is_featured'))
->helperText(__('panel.services.fields.is_featured_hint')),
DateTimePicker::make('published_at')
->label(__('panel.services.fields.published_at'))
->helperText(__('panel.services.fields.published_at_hint'))
->seconds(false)
->disabled(fn () => Auth::user()?->cannot('services.publish'))
->dehydrated(),
DateTimePicker::make('unpublish_at')
->label(__('panel.services.fields.unpublish_at'))
->helperText(__('panel.services.fields.unpublish_at_hint'))
->seconds(false)
->after('published_at')
->disabled(fn () => Auth::user()?->cannot('services.publish'))
->dehydrated(),
]),
Section::make(__('panel.services.sections.seo'))
->collapsed()
->schema([
TranslatableTabs::make(fn (string $locale) => [
TextInput::make("meta_title.{$locale}")
->label(__('panel.services.fields.meta_title'))
->maxLength(60)
->columnSpanFull(),
Textarea::make("meta_description.{$locale}")
->label(__('panel.services.fields.meta_description'))
->rows(2)
->maxLength(160)
->columnSpanFull(),
]),
]),
]);
}
public static function table(Table $table): Table
{
$locale = app()->getLocale();
return $table
// One grouped query for the whole page of rows, so the reading
// figures cost the listing nothing per row.
->modifyQueryUsing(fn (Builder $query) => $query->withRecentViews())
->defaultSort('position')
->reorderable('position')
->columns([
TextColumn::make('title')
->label(__('panel.services.fields.title'))
->weight('medium')
->description(fn (Service $record) => $record->getTranslation('tagline', $locale, false) ?: null)
->wrap()
->searchable(query: fn (Builder $q, string $search) => $q
->where("title->{$locale}", 'like', "%{$search}%")),
TranslationStatusColumn::make('translations')
->label(__('panel.services.fields.translations'))
->trackedAttribute('title'),
// The number that says whether this is an offer or a promise.
TextColumn::make('projects_count')
->label(__('panel.services.fields.projects'))
->counts('projects')
->badge()
->color(fn (int $state) => $state > 0 ? 'success' : 'warning')
->tooltip(fn (int $state) => $state > 0 ? null : __('panel.services.no_projects')),
// What the site is doing, not only what is stored: a service
// waiting for its date is not "published" however the switch
// is set.
IconColumn::make('is_published')
->label(__('panel.services.fields.is_published'))
->boolean()
->tooltip(fn (Service $record) => match (true) {
$record->hasExpired() => __('panel.publishing.expired'),
$record->isScheduled() => __('panel.publishing.scheduled'),
default => null,
})
->color(fn (Service $record) => match (true) {
$record->hasExpired() => 'gray',
$record->isScheduled() => 'info',
default => null,
}),
ViewsColumn::make('recent_views')
->label(__('panel.views.column'))
->tooltip(__('panel.views.column_hint')),
ReviewStatusColumn::make('review')
->label(__('panel.reviews.column')),
IconColumn::make('is_featured')
->label(__('panel.services.fields.is_featured'))
->boolean()
->toggleable(),
])
->filters([
// The reviewer's own queue. Unaddressed requests are in it
// too: a review nobody was named for is everybody's.
Filter::make('awaiting_review')
->label(__('panel.reviews.filters.mine'))
->query(fn (Builder $query) => Auth::user()
? $query->awaitingReviewBy(Auth::user())
: $query->awaitingReview()),
TernaryFilter::make('is_published')->label(__('panel.services.fields.is_published')),
TernaryFilter::make('is_featured')->label(__('panel.services.fields.is_featured')),
// Published and, as far as anybody can tell, read by nobody.
Filter::make('unread')
->label(__('panel.views.filters.unread'))
->query(fn (Builder $query) => $query
->published()
->whereDoesntHave('views', fn (Builder $views) => $views->since(30))),
// Translated and left behind by the original. Worked out
// from the change history rather than a column, so it is
// resolved here and handed to the query as a list.
Filter::make('stale_translation')
->label(__('panel.translations.filters.stale'))
->query(fn (Builder $query) => $query->whereIn(
'id',
TranslationFreshness::staleIds($query->getModel()::query()),
)),
Filter::make('scheduled')
->label(__('panel.publishing.scheduled'))
->query(fn (Builder $query) => $query->scheduled()),
Filter::make('expired')
->label(__('panel.publishing.expired'))
->query(fn (Builder $query) => $query->expired()),
TrashedFilter::make(),
])
->recordActions([
ReviewActions::request(),
ReviewActions::returnForChanges(),
PreviewAction::for('service'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
ReviewActions::requestBulk(Service::class),
BulkAction::make('publish')
->label(__('panel.services.actions.publish'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn () => Auth::user()?->can('services.publish') ?? false)
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
$published = 0;
// One at a time: publishing fires the revision
// entry and closes any pending review, and a mass
// update would skip both without a sound.
foreach ($records as $record) {
if ($record->is_published) {
continue;
}
$record->update([
'is_published' => true,
'published_at' => $record->published_at ?? now(),
]);
$published++;
}
Notification::make()
->title(trans_choice('panel.publishing.published_count', $published, ['count' => $published]))
->success()
->send();
}),
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
/**
* @return array<int, string>
*/
public static function getGloballySearchableAttributes(): array
{
return ['title->'.app()->getLocale()];
}
public static function getRelations(): array
{
return [
ReviewsRelationManager::class,
RevisionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListServices::route('/'),
'create' => CreateService::route('/create'),
'edit' => EditService::route('/{record}/edit'),
];
}
}