Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 109 additions & 15 deletions formwork/src/Pages/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,6 @@ class Page extends Model implements Stringable
#[ReadonlyModelProperty]
protected ?string $route = null;

/**
* Page canonical route
*/
protected ?string $canonicalRoute = null;

/**
* Page slug
*/
Expand Down Expand Up @@ -167,11 +162,6 @@ class Page extends Model implements Stringable
*/
protected Site $site;

/**
* Page icon
*/
protected string $icon;

/**
* @param array<string, mixed> $data
*/
Expand Down Expand Up @@ -315,9 +305,9 @@ public function route(): ?string
*/
public function canonicalRoute(): ?string
{
return $this->canonicalRoute ?? ($this->canonicalRoute = empty($this->data['canonicalRoute'])
return empty($this->data['canonicalRoute'])
? null
: Path::normalize($this->data['canonicalRoute']));
: Path::normalize($this->data['canonicalRoute']);
}

/**
Expand Down Expand Up @@ -379,6 +369,22 @@ public function metadata(): MetadataCollection
return $this->metadata = $metadata;
}

/**
* Set page metadata
*
* @param array<string, mixed>|MetadataCollection $metadata
*/
public function setMetadata(MetadataCollection|array $metadata): void
{
if ($metadata instanceof MetadataCollection) {
$this->metadata = $metadata;
$this->data['metadata'] = $metadata->toArray();
} else {
unset($this->metadata);
$this->data['metadata'] = $metadata;
}
}

/**
* Get page files
*/
Expand Down Expand Up @@ -434,6 +440,24 @@ public function responseStatus(): ResponseStatus
return $this->responseStatus;
}

/**
* Set page HTTP response status
*/
public function setResponseStatus(ResponseStatus|int|null $responseStatus): void
{
if ($responseStatus === null) {
unset($this->responseStatus, $this->data['responseStatus']);
return;
}

if (is_int($responseStatus)) {
$responseStatus = ResponseStatus::fromCode($responseStatus);
}

$this->responseStatus = $responseStatus;
$this->data['responseStatus'] = $responseStatus->code();
}

/**
* Set page language
*
Expand Down Expand Up @@ -641,6 +665,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();
}

/**
* Return whether the slug is editable
*/
Expand Down Expand Up @@ -714,7 +746,7 @@ public function contentRelativePath(): ?string
*/
public function icon(): string
{
return $this->icon ??= $this->data['icon'] ?? $this->scheme()->options()->get('icon', 'page');
return $this->data['icon'] ?? $this->scheme()->options()->get('icon', 'page');
}

/**
Expand All @@ -726,6 +758,49 @@ 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<string, mixed> $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([
'path' => null,
Comment thread
giuscris marked this conversation as resolved.
'canonicalRoute' => null,
'slug' => $this->slug() . '-copy',
Comment thread
giuscris marked this conversation as resolved.
...$with,
]);

$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');
Expand Down Expand Up @@ -765,6 +840,13 @@ public function save(?string $language = null): void
Arr::set($frontmatter, $field->name(), $field->value());
}

// Remove default values without a corresponding field from frontmatter
foreach ($defaults as $key => $defaultValue) {
if (Arr::has($frontmatter, $key) && !$fieldCollection->has($key) && $this->get($key) === $defaultValue) {
Arr::remove($frontmatter, $key);
}
}

$content = str_replace("\r\n", "\n", $this->data['content']);

$contentTemplate = $this->contentFile() !== null
Expand Down Expand Up @@ -803,7 +885,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());
Expand Down Expand Up @@ -931,8 +1017,16 @@ protected function loadFiles(): void
*
* @throws UnexpectedValueException If site path is missing
*/
protected function setPath(string $path): void
protected function setPath(?string $path): void
{
if ($path === null) {
$this->path = null;
$this->relativePath = null;
$this->route = null;
$this->slug = null;
return;
}

$this->path = FileSystem::normalizePath($path . '/');

if ($this->site()->contentPath() === null) {
Expand Down
65 changes: 65 additions & 0 deletions formwork/src/Panel/Controllers/PagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.cannotDuplicate.pageNotFound'), 'error');
return $this->redirectToReferer(default: $this->generateRoute('panel.pages'), base: $this->panel->panelRoot());
}

if (!$page->isDuplicable()) {
$this->panel->notify($this->translate('panel.pages.page.cannotDuplicate.notDuplicable'), '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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions panel/assets/icons/svg/duplicate.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions panel/config/routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions panel/modals/duplicatePage.yaml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion panel/src/ts/components/views/pages.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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") {
Expand Down Expand Up @@ -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"];

Expand Down
4 changes: 4 additions & 0 deletions panel/translations/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ panel.pages.deleteFile: Datei löschen
panel.pages.deleteFile.prompt: Möchten Sie diese Datei wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
panel.pages.deletePage: Seite löschen
panel.pages.deletePage.prompt: Möchten Sie diese Seite wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
panel.pages.duplicatePage: Seite duplizieren
panel.pages.duplicatePage.title: '%s Kopie'
panel.pages.edit: Seite bearbeiten
panel.pages.editPage: Seite bearbeiten %s
panel.pages.history.event.created: Seite erstellt von %s %s.
Expand All @@ -219,6 +221,8 @@ panel.pages.page.cannotCreate.varMissing: Seite kann nicht erstellt werden, eine
panel.pages.page.cannotDelete.invalidLanguage: "Seite kann nicht gelöscht werden, ungültige Sprache: %s"
panel.pages.page.cannotDelete.notDeletable: Seite kann nicht gelöscht werden, die Seite ist nicht löschbar
panel.pages.page.cannotDelete.pageNotFound: Seite kann nicht gelöscht werden, Seite nicht gefunden
panel.pages.page.cannotDuplicate.notDuplicable: Seite kann nicht dupliziert werden, sie ist nicht duplizierbar
panel.pages.page.cannotDuplicate.pageNotFound: Seite kann nicht dupliziert werden, nicht gefunden
panel.pages.page.cannotEdit.alreadyExists: Seite kann nicht bearbeitet werden, eine Seite mit demselben URI existiert bereits
panel.pages.page.cannotEdit.indexOrErrorPageSlug: Slug von Index- und Fehlerseiten kann nicht bearbeitet werden
panel.pages.page.cannotEdit.invalidLanguage: "Seite kann nicht bearbeitet werden, ungültige Sprache: %s"
Expand Down
Loading
Loading