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>
This commit is contained in:
2026-10-01 10:14:51 +02:00
parent 70521d4bc9
commit 366568b793
18 changed files with 486 additions and 64 deletions

View File

@@ -10,6 +10,7 @@ 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;
@@ -20,7 +21,6 @@ use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
@@ -230,17 +230,9 @@ class CallResource extends Resource
->preload(),
])
->recordActions([
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('call'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,8 @@ namespace App\Filament\Resources\Calls\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Calls\CallResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -18,6 +20,9 @@ class EditCall extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('call'),
DuplicateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -7,6 +7,8 @@ namespace App\Filament\Resources\Pages\Pages;
use App\Enums\PageStatus;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Pages\PageResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
@@ -21,6 +23,9 @@ class EditPage extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('page'),
DuplicateAction::make(),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),

View File

@@ -5,6 +5,7 @@ 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;
@@ -20,7 +21,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -211,17 +211,8 @@ class PagesTable
&& Auth::user()?->can('publish', $record))
->action(fn (Page $record) => $record->update(['status' => PageStatus::Draft])),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('page'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,8 @@ namespace App\Filament\Resources\Projects\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -18,6 +20,9 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('project'),
DuplicateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -5,6 +5,7 @@ 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;
@@ -19,7 +20,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\IconColumn;
@@ -197,17 +197,8 @@ class ProjectsTable
&& Auth::user()?->can('publish', $record))
->action(fn (Project $record) => $record->update(['published_at' => now()])),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('project'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -6,6 +6,8 @@ namespace App\Filament\Resources\Services\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Services\ServiceResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -18,6 +20,9 @@ class EditService extends EditRecord
protected function getHeaderActions(): array
{
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('service'),
DuplicateAction::make(),
DeleteAction::make(),
];
}

View File

@@ -9,6 +9,7 @@ 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;
@@ -26,7 +27,6 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -323,17 +323,8 @@ class ServiceResource extends Resource
ReviewActions::request(),
ReviewActions::returnForChanges(),
// A near-copy is a real workflow: the third similar service,
// next year's call. The slug is cleared because two records
// cannot answer at the same URL.
ReplicateAction::make()
->label(__('panel.actions.duplicate'))
->icon('heroicon-o-document-duplicate')
->excludeAttributes(['slug', 'published_at', 'unpublish_at'])
->modalHeading(__('panel.actions.duplicate_heading'))
->successNotificationTitle(__('panel.actions.duplicated')),
PreviewAction::for('service'),
DuplicateAction::make(),
EditAction::make(),
DeleteAction::make(),

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Support\Duplicator;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;
/**
* "Duplicate": a draft copy to start the next one from, opened straight away.
*/
final class DuplicateAction
{
public static function make(): Action
{
return Action::make('duplicate')
->label(__('panel.duplicate.label'))
->icon('heroicon-o-document-duplicate')
->color('gray')
->requiresConfirmation()
->modalHeading(__('panel.duplicate.heading'))
->modalDescription(__('panel.duplicate.hint'))
->authorize('create')
->action(function (Model $record, $livewire): void {
$copy = Duplicator::duplicate($record);
Notification::make()
->title(__('panel.duplicate.done'))
->success()
->send();
$livewire->redirect($livewire::getResource()::getUrl('edit', ['record' => $copy]));
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
use App\Http\Controllers\Controller;
use App\Support\ContentTypes;
use App\Support\Duplicator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* A draft copy of a page, project, service or call — the panel's "Duplicate".
*/
class DuplicateController extends Controller
{
use ResolvesContent;
public function store(Request $request): JsonResponse
{
$record = $this->record($request);
$this->authorize('view', $record);
$this->authorize('create', $record::class);
$copy = Duplicator::duplicate($record);
return response()->json(['data' => [
'type' => $this->content($request),
'id' => $copy->getKey(),
'title' => ContentTypes::title($copy),
'slug' => (object) $copy->getTranslations('slug'),
]], 201);
}
}

121
app/Support/Duplicator.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\PageStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* A copy of a record to start the next one from: same text, same stack, same
* team, but a draft — off the site, out of any review, and with a title and
* an address that say it is a copy.
*/
final class Duplicator
{
/**
* @var array<class-string<Model>, array<int, string>>
*/
public const RELATIONS = [
Page::class => [],
Project::class => ['technologies', 'services', 'members'],
Service::class => ['technologies', 'projects'],
Call::class => ['technologies'],
];
/**
* Never copied: where it stands rather than what it says.
*/
protected const RESET = [
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
];
public static function supports(Model $record): bool
{
return array_key_exists($record::class, self::RELATIONS);
}
public static function duplicate(Model $record): Model
{
if (! self::supports($record)) {
throw new InvalidArgumentException($record::class.' cannot be duplicated.');
}
// Read again: a table row carries aggregates — view counts, summed
// stars — that are not columns and cannot be inserted.
$record = $record->fresh() ?? $record;
return DB::transaction(function () use ($record): Model {
$copy = $record->replicate(array_values(array_filter(
self::RESET,
fn (string $column) => array_key_exists($column, $record->getAttributes()),
)));
$titles = [];
$slugs = [];
foreach ((array) $record->getTranslations('title') as $locale => $title) {
if (filled($title)) {
$titles[$locale] = $title.' '.__('panel.duplicate.title_suffix', [], $locale);
}
}
foreach ((array) $record->getTranslations('slug') as $locale => $slug) {
if (filled($slug)) {
$slugs[$locale] = self::freeSlug($record, $locale, $slug.'-'.__('panel.duplicate.slug_suffix', [], $locale));
}
}
$copy->setTranslations('title', $titles);
$copy->setTranslations('slug', $slugs);
match (true) {
$copy instanceof Page => $copy->forceFill(['status' => PageStatus::Draft, 'author_id' => Auth::id() ?? $record->author_id]),
$copy instanceof Service => $copy->forceFill(['is_published' => false]),
default => null,
};
$copy->save();
foreach (self::RELATIONS[$record::class] as $relation) {
$related = $record->{$relation}()->get();
$copy->{$relation}()->sync(
$relation === 'members'
? $related->mapWithKeys(fn ($member) => [$member->id => ['role' => $member->pivot->role]])->all()
: $related->pluck('id')->all(),
);
}
return $copy;
});
}
/**
* The slug, or the slug with a number after it, whichever nobody has.
*/
protected static function freeSlug(Model $record, string $locale, string $slug): string
{
$candidate = $slug;
$n = 2;
$query = fn (string $value) => $record::query()
->when(method_exists($record, 'bootSoftDeletes'), fn ($q) => $q->withTrashed())
->where("slug->{$locale}", $value)
->exists();
while ($query($candidate)) {
$candidate = "{$slug}-{$n}";
$n++;
}
return $candidate;
}
}