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:
15
README.md
15
README.md
@@ -1156,9 +1156,18 @@ horas y **solo abre ese registro**. El `SITE_PREVIEW_TOKEN` compartido sigue
|
||||
funcionando, pero es un secreto único que hay que pegar en un curl; esto se
|
||||
puede mandar por chat sin dar acceso a todos los borradores del sitio.
|
||||
|
||||
**Duplicar** — en páginas, proyectos, servicios y convocatorias. Copia todo
|
||||
menos el slug y las fechas, porque dos registros no pueden responder en la misma
|
||||
URL.
|
||||
**Duplicar** — en páginas, proyectos, servicios y convocatorias, en la tabla y
|
||||
en el formulario, y por API (`POST /api/v1/{tipo}/{id}/duplicate`). La copia es
|
||||
un **borrador**: mismo contenido, misma tecnología, mismo equipo con sus roles,
|
||||
pero sin publicar, fuera de cualquier revisión, con el título marcado
|
||||
«(copia)» en cada idioma y un slug libre (`hola-copia`, `hola-copia-2`…). Se
|
||||
abre en cuanto se crea. Antes la copia salía sin slug —un registro sin
|
||||
dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
|
||||
página publicada daba otra «publicada» sin URL.
|
||||
|
||||
**Vista previa en el formulario** — el mismo botón de vista previa está también
|
||||
arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira
|
||||
en la web real sin salir de lo que se está escribiendo.
|
||||
|
||||
**Búsqueda global** — los doce recursos, desde la barra de arriba.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
38
app/Filament/Support/DuplicateAction.php
Normal file
38
app/Filament/Support/DuplicateAction.php
Normal 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]));
|
||||
});
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal file
37
app/Http/Controllers/Api/V1/DuplicateController.php
Normal 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
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;
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ return [
|
||||
'select_all' => 'Selecciona-ho tot',
|
||||
],
|
||||
|
||||
'duplicate' => [
|
||||
'label' => 'Duplicar',
|
||||
'heading' => 'Duplicar com a esborrany',
|
||||
'hint' => 'Es crea una còpia amb el mateix contingut, sense publicar i fora de qualsevol revisió, i s\'obre per editar-la.',
|
||||
'done' => 'Còpia creada',
|
||||
'title_suffix' => '(còpia)',
|
||||
'slug_suffix' => 'copia',
|
||||
],
|
||||
|
||||
'permissions' => [
|
||||
'groups' => [
|
||||
'general' => 'General',
|
||||
@@ -182,12 +191,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'duplicate' => 'Duplicar',
|
||||
'duplicate_heading' => 'Duplicar aquest registre',
|
||||
'duplicated' => 'Duplicat. Posa-li un slug i una data abans de publicar-lo.',
|
||||
],
|
||||
|
||||
'preview' => [
|
||||
'label' => 'Vista prèvia',
|
||||
'heading' => 'Enllaços de vista prèvia',
|
||||
|
||||
@@ -144,6 +144,15 @@ return [
|
||||
'select_all' => 'Select all',
|
||||
],
|
||||
|
||||
'duplicate' => [
|
||||
'label' => 'Duplicate',
|
||||
'heading' => 'Duplicate as a draft',
|
||||
'hint' => 'A copy with the same content is made, unpublished and out of any review, and opened for editing.',
|
||||
'done' => 'Copy made',
|
||||
'title_suffix' => '(copy)',
|
||||
'slug_suffix' => 'copy',
|
||||
],
|
||||
|
||||
'permissions' => [
|
||||
'groups' => [
|
||||
'general' => 'General',
|
||||
@@ -182,12 +191,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'duplicate' => 'Duplicate',
|
||||
'duplicate_heading' => 'Duplicate this record',
|
||||
'duplicated' => 'Duplicated. Give it a slug and a date before publishing it.',
|
||||
],
|
||||
|
||||
'preview' => [
|
||||
'label' => 'Preview',
|
||||
'heading' => 'Preview links',
|
||||
|
||||
@@ -144,6 +144,15 @@ return [
|
||||
'select_all' => 'Seleccionar todos',
|
||||
],
|
||||
|
||||
'duplicate' => [
|
||||
'label' => 'Duplicar',
|
||||
'heading' => 'Duplicar como borrador',
|
||||
'hint' => 'Se crea una copia con el mismo contenido, sin publicar y fuera de cualquier revisión, y se abre para editarla.',
|
||||
'done' => 'Copia creada',
|
||||
'title_suffix' => '(copia)',
|
||||
'slug_suffix' => 'copia',
|
||||
],
|
||||
|
||||
'permissions' => [
|
||||
'groups' => [
|
||||
'general' => 'General',
|
||||
@@ -182,12 +191,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'duplicate' => 'Duplicar',
|
||||
'duplicate_heading' => 'Duplicar este registro',
|
||||
'duplicated' => 'Duplicado. Ponle un slug y una fecha antes de publicarlo.',
|
||||
],
|
||||
|
||||
'preview' => [
|
||||
'label' => 'Vista previa',
|
||||
'heading' => 'Enlaces de vista previa',
|
||||
|
||||
@@ -4023,6 +4023,126 @@ paths:
|
||||
schema: { $ref: "#/components/schemas/AuditLogCollection" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/pages/{page}/duplicate:
|
||||
post:
|
||||
tags: [Reviews]
|
||||
summary: Duplicate a page as a draft
|
||||
description: |
|
||||
A copy to start the next one from: the same content and relations,
|
||||
unpublished, out of any review, its title marked as a copy and a free
|
||||
slug in each language.
|
||||
parameters:
|
||||
- name: page
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"201":
|
||||
description: The copy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
type: { type: string }
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
slug: { type: object, additionalProperties: { type: string } }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/projects/{project}/duplicate:
|
||||
post:
|
||||
tags: [Reviews]
|
||||
summary: Duplicate a project as a draft
|
||||
description: |
|
||||
A copy to start the next one from: the same content and relations,
|
||||
unpublished, out of any review, its title marked as a copy and a free
|
||||
slug in each language.
|
||||
parameters:
|
||||
- name: project
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"201":
|
||||
description: The copy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
type: { type: string }
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
slug: { type: object, additionalProperties: { type: string } }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/services/{service}/duplicate:
|
||||
post:
|
||||
tags: [Reviews]
|
||||
summary: Duplicate a service as a draft
|
||||
description: |
|
||||
A copy to start the next one from: the same content and relations,
|
||||
unpublished, out of any review, its title marked as a copy and a free
|
||||
slug in each language.
|
||||
parameters:
|
||||
- name: service
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"201":
|
||||
description: The copy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
type: { type: string }
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
slug: { type: object, additionalProperties: { type: string } }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/calls/{call}/duplicate:
|
||||
post:
|
||||
tags: [Reviews]
|
||||
summary: Duplicate a call as a draft
|
||||
description: |
|
||||
A copy to start the next one from: the same content and relations,
|
||||
unpublished, out of any review, its title marked as a copy and a free
|
||||
slug in each language.
|
||||
parameters:
|
||||
- name: call
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"201":
|
||||
description: The copy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
type: { type: string }
|
||||
id: { type: integer }
|
||||
title: { type: string }
|
||||
slug: { type: object, additionalProperties: { type: string } }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/media:
|
||||
get:
|
||||
tags: [Media]
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\BatchController;
|
||||
use App\Http\Controllers\Api\V1\CallController;
|
||||
use App\Http\Controllers\Api\V1\CategoryController;
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\DuplicateController;
|
||||
use App\Http\Controllers\Api\V1\HealthController;
|
||||
use App\Http\Controllers\Api\V1\MaintenanceController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
@@ -298,6 +299,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post("{$resource}/{{$parameter}}/preview", [PreviewController::class, 'store'])
|
||||
->defaults('content', $resource)
|
||||
->name("{$resource}.preview");
|
||||
|
||||
Route::post("{$resource}/{{$parameter}}/duplicate", [DuplicateController::class, 'store'])
|
||||
->defaults('content', $resource)
|
||||
->name("{$resource}.duplicate");
|
||||
}
|
||||
|
||||
// The library. A file is uploaded here and then referenced from a
|
||||
|
||||
98
tests/Feature/Panel/DuplicateTest.php
Normal file
98
tests/Feature/Panel/DuplicateTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Filament\Resources\Pages\Pages\EditPage;
|
||||
use App\Filament\Resources\Projects\Pages\ListProjects;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Technology;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Duplicating: a draft copy to start the next record from, from the panel or
|
||||
* the API.
|
||||
*/
|
||||
class DuplicateTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_published_page_is_copied_as_a_draft_with_a_free_slug(): void
|
||||
{
|
||||
$this->actingAs($editor = $this->userWithRoles('editor'));
|
||||
|
||||
$page = Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Hola', 'en' => 'Hello'],
|
||||
'slug' => ['es' => 'hola', 'en' => 'hello'],
|
||||
]);
|
||||
Page::factory()->draft()->create(['slug' => ['es' => 'hola-copia']]);
|
||||
|
||||
Livewire::test(EditPage::class, ['record' => $page->getRouteKey()])
|
||||
->callAction('duplicate')
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$copy = Page::query()->latest('id')->first();
|
||||
|
||||
$this->assertSame(PageStatus::Draft, $copy->status);
|
||||
$this->assertNull($copy->published_at);
|
||||
$this->assertSame('Hola (copia)', $copy->getTranslation('title', 'es'));
|
||||
$this->assertSame('Hello (copy)', $copy->getTranslation('title', 'en'));
|
||||
$this->assertSame('hola-copia-2', $copy->getTranslation('slug', 'es'));
|
||||
$this->assertSame($editor->id, $copy->author_id);
|
||||
$this->assertSame($page->body, $copy->body);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_project_keeps_its_stack_and_team(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
$project = Project::factory()->published()->create();
|
||||
$project->technologies()->attach(Technology::factory()->create());
|
||||
$project->members()->attach(Member::factory()->create(), ['role' => 'Responsable']);
|
||||
|
||||
Livewire::test(ListProjects::class)->callTableAction('duplicate', $project);
|
||||
|
||||
$copy = Project::query()->latest('id')->first();
|
||||
|
||||
$this->assertNotSame($project->id, $copy->id);
|
||||
$this->assertNull($copy->published_at);
|
||||
$this->assertCount(1, $copy->technologies);
|
||||
$this->assertSame('Responsable', $copy->members()->first()->pivot->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_api_duplicates_too_and_only_for_who_may_create(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($this->userWithRoles('viewer')));
|
||||
$this->postJson("/api/v1/pages/{$page->id}/duplicate")->assertForbidden();
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($this->userWithRoles('editor')));
|
||||
$this->postJson("/api/v1/pages/{$page->id}/duplicate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.type', 'pages');
|
||||
|
||||
$this->assertSame(2, Page::query()->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user