From 366568b79357700114dde86149a94b49fe08b1a0 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 10:14:51 +0200 Subject: [PATCH] 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 --- README.md | 15 ++- app/Filament/Resources/Calls/CallResource.php | 12 +- .../Resources/Calls/Pages/EditCall.php | 5 + .../Resources/Pages/Pages/EditPage.php | 5 + .../Resources/Pages/Tables/PagesTable.php | 13 +- .../Resources/Projects/Pages/EditProject.php | 5 + .../Projects/Tables/ProjectsTable.php | 13 +- .../Resources/Services/Pages/EditService.php | 5 + .../Resources/Services/ServiceResource.php | 13 +- app/Filament/Support/DuplicateAction.php | 38 ++++++ .../Api/V1/DuplicateController.php | 37 ++++++ app/Support/Duplicator.php | 121 ++++++++++++++++++ lang/ca/panel.php | 15 ++- lang/en/panel.php | 15 ++- lang/es/panel.php | 15 ++- public/openapi.yaml | 120 +++++++++++++++++ routes/api.php | 5 + tests/Feature/Panel/DuplicateTest.php | 98 ++++++++++++++ 18 files changed, 486 insertions(+), 64 deletions(-) create mode 100644 app/Filament/Support/DuplicateAction.php create mode 100644 app/Http/Controllers/Api/V1/DuplicateController.php create mode 100644 app/Support/Duplicator.php create mode 100644 tests/Feature/Panel/DuplicateTest.php diff --git a/README.md b/README.md index 40864bd..8166ac2 100644 --- a/README.md +++ b/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. diff --git a/app/Filament/Resources/Calls/CallResource.php b/app/Filament/Resources/Calls/CallResource.php index 0abe7c1..279239c 100644 --- a/app/Filament/Resources/Calls/CallResource.php +++ b/app/Filament/Resources/Calls/CallResource.php @@ -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(), diff --git a/app/Filament/Resources/Calls/Pages/EditCall.php b/app/Filament/Resources/Calls/Pages/EditCall.php index 98b2bb4..4fc22e0 100644 --- a/app/Filament/Resources/Calls/Pages/EditCall.php +++ b/app/Filament/Resources/Calls/Pages/EditCall.php @@ -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(), ]; } diff --git a/app/Filament/Resources/Pages/Pages/EditPage.php b/app/Filament/Resources/Pages/Pages/EditPage.php index 93f899b..7c98bb4 100644 --- a/app/Filament/Resources/Pages/Pages/EditPage.php +++ b/app/Filament/Resources/Pages/Pages/EditPage.php @@ -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(), diff --git a/app/Filament/Resources/Pages/Tables/PagesTable.php b/app/Filament/Resources/Pages/Tables/PagesTable.php index f1ffe61..30e53a7 100644 --- a/app/Filament/Resources/Pages/Tables/PagesTable.php +++ b/app/Filament/Resources/Pages/Tables/PagesTable.php @@ -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(), diff --git a/app/Filament/Resources/Projects/Pages/EditProject.php b/app/Filament/Resources/Projects/Pages/EditProject.php index 3232677..0f4a3b5 100644 --- a/app/Filament/Resources/Projects/Pages/EditProject.php +++ b/app/Filament/Resources/Projects/Pages/EditProject.php @@ -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(), ]; } diff --git a/app/Filament/Resources/Projects/Tables/ProjectsTable.php b/app/Filament/Resources/Projects/Tables/ProjectsTable.php index ddb70e8..8860a10 100644 --- a/app/Filament/Resources/Projects/Tables/ProjectsTable.php +++ b/app/Filament/Resources/Projects/Tables/ProjectsTable.php @@ -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(), diff --git a/app/Filament/Resources/Services/Pages/EditService.php b/app/Filament/Resources/Services/Pages/EditService.php index 05a1a5a..b70f505 100644 --- a/app/Filament/Resources/Services/Pages/EditService.php +++ b/app/Filament/Resources/Services/Pages/EditService.php @@ -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(), ]; } diff --git a/app/Filament/Resources/Services/ServiceResource.php b/app/Filament/Resources/Services/ServiceResource.php index 5ba818b..f549c8c 100644 --- a/app/Filament/Resources/Services/ServiceResource.php +++ b/app/Filament/Resources/Services/ServiceResource.php @@ -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(), diff --git a/app/Filament/Support/DuplicateAction.php b/app/Filament/Support/DuplicateAction.php new file mode 100644 index 0000000..63c5c88 --- /dev/null +++ b/app/Filament/Support/DuplicateAction.php @@ -0,0 +1,38 @@ +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])); + }); + } +} diff --git a/app/Http/Controllers/Api/V1/DuplicateController.php b/app/Http/Controllers/Api/V1/DuplicateController.php new file mode 100644 index 0000000..6c6eeef --- /dev/null +++ b/app/Http/Controllers/Api/V1/DuplicateController.php @@ -0,0 +1,37 @@ +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); + } +} diff --git a/app/Support/Duplicator.php b/app/Support/Duplicator.php new file mode 100644 index 0000000..e852e8b --- /dev/null +++ b/app/Support/Duplicator.php @@ -0,0 +1,121 @@ +, array> + */ + 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; + } +} diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 7bd3b87..c755e58 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -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', diff --git a/lang/en/panel.php b/lang/en/panel.php index b3cf80e..7ab8977 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -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', diff --git a/lang/es/panel.php b/lang/es/panel.php index 5d87756..bebb2fc 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -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', diff --git a/public/openapi.yaml b/public/openapi.yaml index 1037567..6a3f979 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/routes/api.php b/routes/api.php index 2fc3f86..81eccd5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 diff --git a/tests/Feature/Panel/DuplicateTest.php b/tests/Feature/Panel/DuplicateTest.php new file mode 100644 index 0000000..3809c37 --- /dev/null +++ b/tests/Feature/Panel/DuplicateTest.php @@ -0,0 +1,98 @@ +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()); + } +}