Files
landing-api/app/Filament/Resources/Pages/Tables/PagesTable.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

264 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\Pages\Tables;
use App\Enums\PageStatus;
use App\Filament\Support\DuplicateAction;
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;
use App\Support\TranslationFreshness;
use Filament\Actions\Action;
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\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
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\Support\Facades\Auth;
class PagesTable
{
public static function configure(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('created_at', 'desc')
->columns([
TextColumn::make('title')
->label(__('panel.pages.fields.title'))
->weight('medium')
->description(fn (Page $record) => $record->getTranslation('slug', $locale, false) ?: null)
->searchable(query: fn (Builder $query, string $search) => $query
->where("title->{$locale}", 'like', "%{$search}%"))
->sortable(query: fn (Builder $query, string $direction) => $query
->orderBy("title->{$locale}", $direction)),
TranslationStatusColumn::make('translations')
->label(__('panel.pages.fields.translations'))
->trackedAttribute('title'),
// The state the *site* is in, not only the one stored. A
// page waiting for its date, or one whose date has passed, is
// not "Published" however the column reads.
TextColumn::make('status')
->label(__('panel.pages.fields.status'))
->badge()
->formatStateUsing(fn (PageStatus $state, Page $record) => match (true) {
$record->hasExpired() => __('panel.publishing.expired'),
$record->isScheduled() && $state === PageStatus::Published => __('panel.publishing.scheduled'),
default => $state->getLabel(),
})
->color(fn (PageStatus $state, Page $record) => match (true) {
$record->hasExpired() => 'gray',
$record->isScheduled() && $state === PageStatus::Published => 'info',
default => $state->getColor(),
})
->description(fn (Page $record) => match (true) {
$record->hasExpired() => __('panel.publishing.expired_at', ['time' => $record->unpublish_at->isoFormat('LLL')]),
$record->isScheduled() => $record->published_at->diffForHumans(),
$record->expiresSoon() => __('panel.publishing.expires_in', ['time' => $record->unpublish_at->diffForHumans()]),
default => null,
})
->sortable(),
ReviewStatusColumn::make('review')
->label(__('panel.reviews.column')),
TextColumn::make('category.name')
->label(__('panel.pages.fields.category'))
->badge()
->color('gray')
->toggleable(),
TextColumn::make('author.name')
->label(__('panel.pages.fields.author'))
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('is_featured')
->label(__('panel.pages.fields.is_featured'))
->boolean()
->toggleable(),
ViewsColumn::make('recent_views')
->label(__('panel.views.column'))
->tooltip(__('panel.views.column_hint')),
TextColumn::make('published_at')
->label(__('panel.pages.fields.published_at'))
->dateTime()
->sortable()
->placeholder('—'),
])
->filters([
SelectFilter::make('status')
->label(__('panel.pages.fields.status'))
->options(PageStatus::options())
->multiple(),
// 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()),
SelectFilter::make('category')
->label(__('panel.pages.fields.category'))
->relationship('category', 'name->'.config('locales.default'))
->getOptionLabelFromRecordUsing(TranslatedOptionLabel::using('name'))
->searchable()
->preload(),
TernaryFilter::make('is_featured')
->label(__('panel.pages.fields.is_featured')),
// The editorial workhorse: "what still needs translating?"
SelectFilter::make('missing_translation')
->label(__('panel.pages.filters.missing_translation'))
->options(fn () => collect(config('locales.supported'))
->map(fn (array $meta, string $code) => $meta['native'] ?? $code)
->all())
->query(function (Builder $query, array $data): Builder {
if (blank($data['value'] ?? null)) {
return $query;
}
$target = $data['value'];
return $query->where(fn (Builder $q) => $q
->whereNull("title->{$target}")
->orWhere("title->{$target}", ''));
}),
// 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('published')
->label(__('enums.page_status.published'))
->query(fn (Builder $query) => $query->published()),
// 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))),
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(),
// One-click publish for users who hold the separate
// pages.publish permission.
Action::make('publish')
->label(__('panel.pages.actions.publish'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn (Page $record) => $record->status !== PageStatus::Published
&& Auth::user()?->can('publish', $record))
->action(fn (Page $record) => $record->update([
'status' => PageStatus::Published,
'published_at' => $record->published_at ?? now(),
])),
Action::make('unpublish')
->label(__('panel.pages.actions.unpublish'))
->icon('heroicon-o-eye-slash')
->color('warning')
->requiresConfirmation()
->visible(fn (Page $record) => $record->status === PageStatus::Published
&& Auth::user()?->can('publish', $record))
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])),
PreviewAction::for('page'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
ReviewActions::requestBulk(Page::class),
BulkAction::make('publish')
->label(__('panel.pages.actions.publish'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn () => Auth::user()?->can('pages.publish') ?? false)
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
$published = 0;
// One at a time, not a single UPDATE across every
// selected row: 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->status === PageStatus::Published) {
continue;
}
$record->update([
'status' => PageStatus::Published,
'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(),
]),
]);
}
}