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:
121
app/Support/Duplicator.php
Normal file
121
app/Support/Duplicator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user