From 262f21f1a5e15f85ea68068b6f9cc4a5bac82b71 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 9 Nov 2025 19:29:58 +0100 Subject: [PATCH 1/4] Add the possibility to duplicate pages --- formwork/src/Pages/Page.php | 56 +++++++++++++++- .../src/Panel/Controllers/PagesController.php | 65 +++++++++++++++++++ panel/assets/icons/svg/duplicate.svg | 1 + panel/config/routes/routes.php | 6 ++ panel/modals/duplicatePage.yaml | 30 +++++++++ panel/src/ts/components/views/pages.ts | 19 +++++- panel/translations/en.yaml | 2 + panel/views/pages/editor.php | 16 +++-- panel/views/pages/tree.php | 6 +- 9 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 panel/assets/icons/svg/duplicate.svg create mode 100644 panel/modals/duplicatePage.yaml diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index 98c4a8c76..21fcd582e 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -641,6 +641,14 @@ public function isDeletable(): bool return !($this->hasChildren() || $this->isIndexPage() || $this->isErrorPage()); } + /** + * Return whether the page is duplicable + */ + public function isDuplicable(): bool + { + return !($this->hasChildren() || $this->isIndexPage() || $this->isErrorPage()); + } + /** * Return whether the slug is editable */ @@ -726,6 +734,48 @@ public function icon(): string * @throws InvalidValueException If the language is invalid */ public function save(?string $language = null): void + { + $this->write($language, copy: false); + } + + /** + * Duplicate the page + * + * @param array $with Data to override in the duplicated page + * @param string|null $language Language code to duplicate the page in + * + * @throws UnexpectedValueException If parent or parent content path is missing + * @throws InvalidValueException If the language is invalid + */ + public function duplicate(array $with = [], ?string $language = null): Page + { + if (!$this->isDuplicable()) { + throw new RuntimeException('Cannot duplicate a non-duplicable page'); + } + + $duplicatePage = clone $this; + + $duplicatePage->setMultiple($with); + + if (!isset($with['slug'])) { + $duplicatePage->setSlug($this->slug() . '-copy'); + } + + $duplicatePage->write($language, copy: true); + + return $duplicatePage; + } + + /** + * Write page contents and move or copy files if needed + * + * @param string|null $language Language code to save the page in + * @param bool $copy Whether to copy the page instead of moving it + * + * @throws UnexpectedValueException If parent or parent content path is missing + * @throws InvalidValueException If the language is invalid + */ + protected function write(?string $language = null, bool $copy = false): void { if ($this->parent() === null) { throw new UnexpectedValueException('Unexpected missing parent'); @@ -803,7 +853,11 @@ public function save(?string $language = null): void FileSystem::createDirectory($contentPath, recursive: true); } if ($this->contentPath() !== null) { - FileSystem::moveDirectory($this->contentPath(), $contentPath, overwrite: FileSystem::isEmptyDirectory($contentPath, assertExists: false)); + if ($copy) { + FileSystem::copyDirectory($this->contentPath(), $contentPath, overwrite: FileSystem::isEmptyDirectory($contentPath, assertExists: false)); + } else { + FileSystem::moveDirectory($this->contentPath(), $contentPath, overwrite: FileSystem::isEmptyDirectory($contentPath, assertExists: false)); + } } } elseif ($contentTemplate !== $this->template->name() && $this->contentFile() !== null) { FileSystem::delete($this->contentFile()->path()); diff --git a/formwork/src/Panel/Controllers/PagesController.php b/formwork/src/Panel/Controllers/PagesController.php index a948ca83e..e86db2ba3 100644 --- a/formwork/src/Panel/Controllers/PagesController.php +++ b/formwork/src/Panel/Controllers/PagesController.php @@ -120,6 +120,53 @@ public function create(PageFactory $pageFactory): Response return $this->redirect($this->generateRoute('panel.pages.edit', ['page' => trim($page->route(), '/')])); } + /** + * Pages@duplicate action + */ + public function duplicate(RouteParams $routeParams): Response + { + if (!$this->hasPermission('panel.pages.duplicate')) { + return $this->forward(ErrorsController::class, 'forbidden'); + } + + $requestData = $this->request->input(); + + $fields = $this->modal('duplicatePage')->fields(); + + $page = $this->site->findPage($routeParams->get('page')); + + if ($page === null) { + $this->panel->notify($this->translate('panel.pages.page.cannotEdit.pageNotFound'), 'error'); + return $this->redirectToReferer(default: $this->generateRoute('panel.pages'), base: $this->panel->panelRoot()); + } + + if ($page->hasChildren()) { + $this->panel->notify($this->translate('panel.pages.page.cannotEdit.pageNotFound'), 'error'); + return $this->redirectToReferer(default: $this->generateRoute('panel.pages'), base: $this->panel->panelRoot()); + } + + try { + $fields->setValues($requestData)->validate(); + + // Let's duplicate the page + $duplicatePage = $this->duplicatePage($page, $fields); + $this->panel->notify($this->translate('panel.pages.page.created'), 'success'); + } catch (TranslatedException $e) { + $this->panel->notify($this->translate($e->getLanguageString()), 'error'); + return $this->redirectToReferer(default: $this->generateRoute('panel.pages'), base: $this->panel->panelRoot()); + } catch (InvalidValueException $e) { + $identifier = $e->getIdentifier() ?? 'varMissing'; + $this->panel->notify($this->translate('panel.pages.page.cannotCreate.' . $identifier), 'error'); + return $this->redirectToReferer(default: $this->generateRoute('panel.pages'), base: $this->panel->panelRoot()); + } + + if ($duplicatePage->route() === null) { + throw new UnexpectedValueException('Unexpected missing page route'); + } + + return $this->redirect($this->generateRoute('panel.pages.edit', ['page' => trim($duplicatePage->route(), '/')])); + } + /** * Pages@edit action */ @@ -475,6 +522,24 @@ private function createPage(FieldCollection $fieldCollection, PageFactory $pageF return $page; } + /** + * Duplicate a page + */ + private function duplicatePage(Page $page, FieldCollection $fieldCollection): Page + { + $data = [...$fieldCollection->everyItem()->value()->toArray(), 'published' => false]; + + $duplicatePage = $page->duplicate($data); + + if ($duplicatePage->contentPath()) { + $contentHistory = new ContentHistory($duplicatePage->contentPath()); + $contentHistory->update(ContentHistoryEvent::Created, $this->panel->user()->username(), time()); + $contentHistory->save(); + } + + return $duplicatePage; + } + /** * Update a page */ diff --git a/panel/assets/icons/svg/duplicate.svg b/panel/assets/icons/svg/duplicate.svg new file mode 100644 index 000000000..695e962ab --- /dev/null +++ b/panel/assets/icons/svg/duplicate.svg @@ -0,0 +1 @@ + diff --git a/panel/config/routes/routes.php b/panel/config/routes/routes.php index 4a1bdc00c..7b390ec2d 100644 --- a/panel/config/routes/routes.php +++ b/panel/config/routes/routes.php @@ -91,6 +91,12 @@ 'types' => ['XHR'], ], + 'panel.pages.duplicate' => [ + 'path' => '/pages/{page:all}/duplicate/', + 'action' => 'Formwork\Panel\Controllers\PagesController@duplicate', + 'methods' => ['POST'], + ], + 'panel.pages.delete' => [ 'path' => '/pages/{page:all}/delete/', 'action' => 'Formwork\Panel\Controllers\PagesController@delete', diff --git a/panel/modals/duplicatePage.yaml b/panel/modals/duplicatePage.yaml new file mode 100644 index 000000000..3514be0bd --- /dev/null +++ b/panel/modals/duplicatePage.yaml @@ -0,0 +1,30 @@ +title: '{{panel.pages.duplicatePage}}' + +action: /pages/duplicate/ + +fields: + title: + type: text + label: '{{page.title}}' + required: true + + slug: + type: slug + label: '{{page.slug}}' + suggestion: '{{page.slug.suggestion}}' + required: true + pattern: '[a-z0-9\-]+' + source: title + +buttons: + dismiss: + action: dismiss + icon: times-circle + label: '{{panel.modal.action.cancel}}' + variant: secondary + + submit: + action: submit + icon: check-circle + label: '{{panel.modal.action.continue}}' + align: right diff --git a/panel/src/ts/components/views/pages.ts b/panel/src/ts/components/views/pages.ts index 477515e1c..314ef22a0 100644 --- a/panel/src/ts/components/views/pages.ts +++ b/panel/src/ts/components/views/pages.ts @@ -1,5 +1,5 @@ import { $, $$ } from "../../utils/selectors"; -import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation"; +import { escapeRegExp, makeDiacriticsRegExp, makeSlug } from "../../utils/validation"; import { app } from "../../app"; import { debounce } from "../../utils/events"; import { Form } from "../form"; @@ -19,6 +19,7 @@ export class Pages { const newPageModal = app.modals["newPageModal"]; const deletePageItemModal = app.modals["deletePageItemModal"]; + const duplicatePageModal = app.modals["duplicatePageModal"]; $$(".pages-tree").forEach((element) => { if (element.dataset.orderableChildren === "true") { @@ -214,6 +215,22 @@ export class Pages { }); } + if (duplicatePageModal) { + duplicatePageModal.onOpen((modal, trigger) => { + if (trigger && modal.form) { + const duplicateTitle = trigger.dataset.duplicateTitle; + + if (duplicateTitle) { + const title = $('[name="duplicatePageModal[title]"]', modal.form.element) as HTMLInputElement; + const slug = $('[name="duplicatePageModal[slug]"]', modal.form.element) as HTMLInputElement; + title.value = duplicateTitle; + slug.value = makeSlug(duplicateTitle); + title.setSelectionRange(0, title.value.length); + } + } + }); + } + if (commandPreview) { const editorForm = app.forms["page-editor-form"]; diff --git a/panel/translations/en.yaml b/panel/translations/en.yaml index d899e3ca3..45eb4f04c 100644 --- a/panel/translations/en.yaml +++ b/panel/translations/en.yaml @@ -204,6 +204,8 @@ panel.pages.history.event.edited: Page edited by %s %s. panel.pages.languages: Languages panel.pages.languages.addLanguage: Add %s panel.pages.languages.editLanguage: Edit %s +panel.pages.duplicatePage: Duplicate page +panel.pages.duplicatePage.title: '%s copy' panel.pages.newPage: New page panel.pages.newPage.site: Site panel.pages.next: Next page diff --git a/panel/views/pages/editor.php b/panel/views/pages/editor.php index 3793a7afc..680ea27ff 100644 --- a/panel/views/pages/editor.php +++ b/panel/views/pages/editor.php @@ -1,6 +1,6 @@ layout('panel') ?> -modals()->addMultiple(['changes', 'deletePage']) ?> +modals()->addMultiple(['changes', 'deletePage', 'duplicatePage']) ?>
attr(['hidden' => true, 'aria-hidden' => 'true', 'tabindex' => -1, 'data-command' => 'save', 'formaction' => $history?->isJustCreated() ? '?publish=false' : null]) ?>> @@ -28,9 +28,17 @@ href="uri('/pages/' . trim($nextPage->route(), '/') . '/edit/') ?>" title="translate('panel.pages.next') ?>" aria-label="translate('panel.pages.next') ?>">icon('chevron-right') ?> published() && $page->routable()) : ?>href="uri(includeLanguage: $currentLanguage ?: true) ?>" target="formwork-view-page-uid() ?>" title="translate('panel.pages.viewPage') ?>" aria-label="translate('panel.pages.viewPage') ?>">icon('arrow-right-up-box') ?> - user()->permissions()->has('panel.pages.delete')) : ?> - - + languages()->hasMultiple()) : ?>