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

246 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\Projects\Tables;
use App\Enums\ProjectStatus;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\ReviewActions;
use App\Filament\Support\ReviewStatusColumn;
use App\Filament\Support\TranslationStatusColumn;
use App\Filament\Support\ViewsColumn;
use App\Models\Project;
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 ProjectsTable
{
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('position')
->reorderable('position')
->columns([
TextColumn::make('title')
->label(__('panel.projects.fields.title'))
->weight('medium')
->description(fn (Project $record) => $record->getTranslation('tagline', $locale, false) ?: null)
->wrap()
->searchable(query: fn (Builder $q, string $search) => $q
->where("title->{$locale}", 'like', "%{$search}%"))
->sortable(query: fn (Builder $q, string $direction) => $q
->orderBy("title->{$locale}", $direction)),
TranslationStatusColumn::make('translations')
->label(__('panel.pages.fields.translations'))
->trackedAttribute('title'),
TextColumn::make('status')
->label(__('panel.projects.fields.status'))
->badge()
->sortable(),
TextColumn::make('technologies.name')
->label(__('panel.projects.fields.technologies'))
->badge()
->color('gray')
->limitList(3)
->expandableLimitedList()
->toggleable(),
// Live repository activity, so the panel shows the same signs
// of life the public site does.
TextColumn::make('repositories_sum_stars')
->label('★')
->sum('repositories', 'stars')
->badge()
->color('warning')
->toggleable(),
IconColumn::make('needs_help')
->label(__('panel.projects.fields.needs_help'))
->boolean()
->trueIcon('heroicon-o-hand-raised')
->falseIcon('heroicon-o-minus-small')
->trueColor('warning')
->falseColor('gray')
->sortable(),
IconColumn::make('is_featured')
->label(__('panel.projects.fields.is_featured'))
->boolean()
->toggleable(),
// A project's status is its lifecycle, not its visibility —
// those are different questions, so the window gets its own
// column rather than overwriting the answer to the first.
ViewsColumn::make('recent_views')
->label(__('panel.views.column'))
->tooltip(__('panel.views.column_hint')),
ReviewStatusColumn::make('review')
->label(__('panel.reviews.column')),
TextColumn::make('published_at')
->label(__('panel.projects.fields.published_at'))
->badge()
->formatStateUsing(fn (?string $state, Project $record) => match (true) {
$record->hasExpired() => __('panel.publishing.expired'),
$record->isScheduled() => __('panel.publishing.scheduled'),
blank($state) => __('panel.publishing.unpublished'),
default => __('panel.publishing.live'),
})
->color(fn (?string $state, Project $record) => match (true) {
$record->hasExpired() => 'gray',
$record->isScheduled() => 'info',
blank($state) => 'gray',
default => 'success',
})
->description(fn (Project $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 => $record->published_at?->isoFormat('LL'),
})
->sortable()
->placeholder('—'),
])
->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()),
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()),
SelectFilter::make('status')
->label(__('panel.projects.fields.status'))
->options(ProjectStatus::options())
->multiple(),
SelectFilter::make('technologies')
->label(__('panel.projects.fields.technologies'))
->relationship('technologies', 'name')
->multiple()
->preload(),
TernaryFilter::make('needs_help')
->label(__('panel.projects.fields.needs_help')),
// 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('unpublished')
->label(__('panel.projects.filters.unpublished'))
->query(fn (Builder $q) => $q->whereNull('published_at')),
TrashedFilter::make(),
])
->recordActions([
ReviewActions::request(),
ReviewActions::returnForChanges(),
Action::make('publish')
->label(__('panel.projects.actions.publish'))
->icon('heroicon-o-globe-alt')
->color('success')
->requiresConfirmation()
->visible(fn (Project $record) => $record->published_at === null
&& Auth::user()?->can('publish', $record))
->action(fn (Project $record) => $record->update(['published_at' => now()])),
PreviewAction::for('project'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
ReviewActions::requestBulk(Project::class),
BulkAction::make('publish')
->label(__('panel.projects.actions.publish'))
->icon('heroicon-o-globe-alt')
->color('success')
->requiresConfirmation()
->visible(fn () => Auth::user()?->can('projects.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->published_at !== null) {
continue;
}
$record->update(['published_at' => now()]);
$published++;
}
Notification::make()
->title(trans_choice('panel.publishing.published_count', $published, ['count' => $published]))
->success()
->send();
}),
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
}